diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad771b759c..2c497442dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,6 +139,16 @@ android { "libimagedecoder", "libquickjs", "libsqlite3x", + "libmpv", + "libavcodec", + "libavformat", + "libswscale", + "libavutil", + "libswresample", + "libavfilter", + "libass", + "libdav1d", + "libplacebo", ) .map { "**/$it.so" } } @@ -345,6 +355,17 @@ dependencies { testImplementation(kotlinx.coroutines.test) + // MPV player + implementation(libs.aniyomi.mpv) + implementation(libs.seeker) + implementation(libs.ffmpeg.kit) + implementation(libs.smart.exception.java) + implementation(libs.mediasession) + implementation(libs.truetypeparser) + implementation(libs.torrentserver) + implementation(libs.media.router) + implementation(libs.cast.play.services) + // SY --> // Better logging (EH) implementation(sylibs.xlog) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ba79259daa..ecd583c0c2 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,6 +18,10 @@ -keep class com.canopus.** { *; } -keepclassmembers class com.canopus.** { *; } +# MPV native player +-keep class is.xyz.mpv.** { *; } +-keepclassmembers class is.xyz.mpv.** { *; } + # Injekt type resolution - FullTypeReference needs generic type info -keep class * extends uy.kohesive.injekt.api.TypeReference { *; } -keep class * extends uy.kohesive.injekt.api.FullTypeReference { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 29f838a431..a2cd4e836d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,7 +14,8 @@ + android:maxSdkVersion="28" + tools:replace="android:maxSdkVersion" /> + + + + + + + + + + + + ().isInitialized }.collectAsState().value } + +@Composable +fun ifAnimeSourcesLoaded(): Boolean { + return remember { Injekt.get().isInitialized }.collectAsState().value +} diff --git a/app/src/main/java/eu/kanade/domain/AnimeDomainModule.kt b/app/src/main/java/eu/kanade/domain/AnimeDomainModule.kt new file mode 100644 index 0000000000..1594874c2d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/AnimeDomainModule.kt @@ -0,0 +1,57 @@ +package eu.kanade.domain + +import tachiyomi.data.anime.AnimeRepositoryImpl +import tachiyomi.data.category.AnimeCategoryRepositoryImpl +import tachiyomi.data.episode.EpisodeRepositoryImpl +import tachiyomi.domain.anime.interactor.GetAnime +import tachiyomi.domain.anime.interactor.GetDuplicateLibraryAnime +import tachiyomi.domain.anime.interactor.GetFavoriteAnime +import tachiyomi.domain.anime.interactor.GetLibraryAnime +import tachiyomi.domain.anime.interactor.SetAnimeEpisodeFlags +import tachiyomi.domain.anime.interactor.UpdateAnime +import tachiyomi.domain.anime.repository.AnimeRepository +import tachiyomi.domain.category.interactor.CreateAnimeCategory +import tachiyomi.domain.category.interactor.DeleteAnimeCategory +import tachiyomi.domain.category.interactor.GetAnimeCategories +import tachiyomi.domain.category.interactor.SetAnimeCategories +import tachiyomi.domain.category.repository.AnimeCategoryRepository +import tachiyomi.domain.episode.interactor.GetEpisode +import tachiyomi.domain.episode.interactor.GetEpisodesByAnimeId +import tachiyomi.domain.episode.interactor.SetSeenStatus +import tachiyomi.domain.episode.interactor.ShouldUpdateDbEpisode +import tachiyomi.domain.episode.interactor.UpdateEpisode +import tachiyomi.domain.episode.repository.EpisodeRepository +import tachiyomi.domain.library.service.AnimeLibraryPreferences +import uy.kohesive.injekt.api.InjektModule +import uy.kohesive.injekt.api.InjektRegistrar +import uy.kohesive.injekt.api.addFactory +import uy.kohesive.injekt.api.addSingletonFactory +import uy.kohesive.injekt.api.get + +class AnimeDomainModule : InjektModule { + + override fun InjektRegistrar.registerInjectables() { + addSingletonFactory { AnimeRepositoryImpl(get()) } + addFactory { GetAnime(get()) } + addFactory { UpdateAnime(get()) } + addFactory { GetFavoriteAnime(get()) } + addFactory { GetLibraryAnime(get()) } + addFactory { SetAnimeEpisodeFlags(get()) } + addFactory { GetDuplicateLibraryAnime(get()) } + + addSingletonFactory { EpisodeRepositoryImpl(get()) } + addFactory { GetEpisode(get()) } + addFactory { GetEpisodesByAnimeId(get()) } + addFactory { UpdateEpisode(get()) } + addFactory { SetSeenStatus(get()) } + addFactory { ShouldUpdateDbEpisode() } + + addSingletonFactory { AnimeCategoryRepositoryImpl(get()) } + addFactory { GetAnimeCategories(get()) } + addFactory { CreateAnimeCategory(get()) } + addFactory { DeleteAnimeCategory(get()) } + addFactory { SetAnimeCategories(get()) } + + addSingletonFactory { AnimeLibraryPreferences(get()) } + } +} diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 30c3244bea..9aaf9ed507 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -1,8 +1,12 @@ package eu.kanade.domain +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources +import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionsByType import eu.kanade.domain.chapter.interactor.GetAvailableScanlators import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource +import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.extension.interactor.GetExtensionLanguages import eu.kanade.domain.extension.interactor.GetExtensionSources @@ -25,7 +29,15 @@ import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.domain.track.interactor.RefreshTracks import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack import eu.kanade.domain.track.interactor.TrackChapter +import mihon.data.repository.AnimeExtensionRepoRepositoryImpl import mihon.data.repository.ExtensionRepoRepositoryImpl +import mihon.domain.animeextensionrepo.interactor.CreateAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.DeleteAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.GetAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.GetAnimeExtensionRepoCount +import mihon.domain.animeextensionrepo.interactor.ReplaceAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.UpdateAnimeExtensionRepo +import mihon.domain.animeextensionrepo.repository.AnimeExtensionRepoRepository import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo @@ -37,8 +49,10 @@ import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extensionrepo.service.ExtensionRepoService import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.upcoming.interactor.GetUpcomingManga +import tachiyomi.data.animesource.StubAnimeSourceRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.chapter.ChapterRepositoryImpl +import tachiyomi.data.history.AnimeHistoryRepositoryImpl import tachiyomi.data.history.HistoryRepositoryImpl import tachiyomi.data.manga.MangaRepositoryImpl import tachiyomi.data.release.ReleaseServiceImpl @@ -46,6 +60,7 @@ import tachiyomi.data.source.SourceRepositoryImpl import tachiyomi.data.source.StubSourceRepositoryImpl import tachiyomi.data.track.TrackRepositoryImpl import tachiyomi.data.updates.UpdatesRepositoryImpl +import tachiyomi.domain.animesource.repository.StubAnimeSourceRepository import tachiyomi.domain.category.interactor.CreateCategoryWithName import tachiyomi.domain.category.interactor.DeleteCategory import tachiyomi.domain.category.interactor.GetCategories @@ -65,11 +80,15 @@ import tachiyomi.domain.chapter.interactor.SetMangaDefaultChapterFlags import tachiyomi.domain.chapter.interactor.ShouldUpdateDbChapter import tachiyomi.domain.chapter.interactor.UpdateChapter import tachiyomi.domain.chapter.repository.ChapterRepository +import tachiyomi.domain.history.interactor.GetAnimeHistory import tachiyomi.domain.history.interactor.GetHistory import tachiyomi.domain.history.interactor.GetNextChapters import tachiyomi.domain.history.interactor.GetTotalReadDuration +import tachiyomi.domain.history.interactor.RemoveAnimeHistory import tachiyomi.domain.history.interactor.RemoveHistory +import tachiyomi.domain.history.interactor.UpsertAnimeHistory import tachiyomi.domain.history.interactor.UpsertHistory +import tachiyomi.domain.history.repository.AnimeHistoryRepository import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.manga.interactor.FetchInterval import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga @@ -175,7 +194,13 @@ class DomainModule : InjektModule { addFactory { RemoveHistory(get()) } addFactory { GetTotalReadDuration(get()) } + addSingletonFactory { AnimeHistoryRepositoryImpl(get()) } + addFactory { GetAnimeHistory(get()) } + addFactory { UpsertAnimeHistory(get()) } + addFactory { RemoveAnimeHistory(get()) } + addFactory { DeleteDownload(get(), get()) } + addFactory { DeleteAnimeDownload(get(), get()) } addFactory { GetExtensionsByType(get(), get()) } addFactory { GetExtensionSources(get()) } @@ -205,6 +230,19 @@ class DomainModule : InjektModule { addFactory { DeleteExtensionRepo(get()) } addFactory { ReplaceExtensionRepo(get()) } addFactory { UpdateExtensionRepo(get(), get()) } + + addSingletonFactory { AnimeExtensionRepoRepositoryImpl(get()) } + addSingletonFactory { StubAnimeSourceRepositoryImpl(get()) } + addFactory { GetAnimeExtensionRepo(get()) } + addFactory { GetAnimeExtensionRepoCount(get()) } + addFactory { CreateAnimeExtensionRepo(get(), get()) } + addFactory { DeleteAnimeExtensionRepo(get()) } + addFactory { ReplaceAnimeExtensionRepo(get()) } + addFactory { UpdateAnimeExtensionRepo(get(), get()) } + addFactory { GetAnimeExtensionsByType(get(), get()) } + addFactory { GetAnimeExtensionLanguages(get(), get()) } + addFactory { GetAnimeExtensionSources(get()) } + addFactory { ToggleIncognito(get()) } addFactory { GetIncognitoState(get(), get(), get()) } } diff --git a/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteAnimeDownload.kt b/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteAnimeDownload.kt new file mode 100644 index 0000000000..e057003b0e --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animedownload/interactor/DeleteAnimeDownload.kt @@ -0,0 +1,19 @@ +package eu.kanade.domain.animedownload.interactor + +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager +import tachiyomi.core.common.util.lang.withNonCancellableContext +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.animesource.service.AnimeSourceManager +import tachiyomi.domain.episode.model.Episode + +class DeleteAnimeDownload( + private val animeSourceManager: AnimeSourceManager, + private val animeDownloadManager: AnimeDownloadManager, +) { + + suspend fun awaitAll(anime: Anime, vararg episodes: Episode) = withNonCancellableContext { + animeSourceManager.get(anime.source)?.let { source -> + animeDownloadManager.deleteEpisodes(episodes.toList(), anime, source) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt new file mode 100644 index 0000000000..64a9da60ea --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionLanguages.kt @@ -0,0 +1,32 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.util.system.LocaleHelper +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class GetAnimeExtensionLanguages( + private val preferences: SourcePreferences, + private val animeExtensionManager: AnimeExtensionManager, +) { + fun subscribe(): Flow> { + return combine( + preferences.enabledLanguages().changes(), + animeExtensionManager.availableExtensionsFlow, + ) { enabledLanguage, availableExtensions -> + availableExtensions + .flatMap { ext -> + if (ext.sources.isEmpty()) { + listOf(ext.lang) + } else { + ext.sources.map { it.lang } + } + } + .distinct() + .sortedWith( + compareBy { it !in enabledLanguage }.then(LocaleHelper.comparator), + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt new file mode 100644 index 0000000000..66d08c062e --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionSources.kt @@ -0,0 +1,37 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animesource.AnimeSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetAnimeExtensionSources( + private val preferences: SourcePreferences, +) { + + fun subscribe(extension: AnimeExtension.Installed): Flow> { + val isMultiSource = extension.sources.size > 1 + val isMultiLangSingleSource = + isMultiSource && extension.sources.map { it.name }.distinct().size == 1 + + return preferences.disabledSources().changes().map { disabledSources -> + fun AnimeSource.isEnabled() = id.toString() !in disabledSources + + extension.sources + .map { source -> + AnimeExtensionSourceItem( + source = source, + enabled = source.isEnabled(), + labelAsName = isMultiSource && !isMultiLangSingleSource, + ) + } + } + } +} + +data class AnimeExtensionSourceItem( + val source: AnimeSource, + val enabled: Boolean, + val labelAsName: Boolean, +) diff --git a/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt new file mode 100644 index 0000000000..06707f4fe7 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/interactor/GetAnimeExtensionsByType.kt @@ -0,0 +1,60 @@ +package eu.kanade.domain.animeextension.interactor + +import eu.kanade.domain.animeextension.model.AnimeExtensions +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +class GetAnimeExtensionsByType( + private val preferences: SourcePreferences, + private val animeExtensionManager: AnimeExtensionManager, +) { + + fun subscribe(): Flow { + val showNsfwSources = preferences.showNsfwSource().get() + + return combine( + preferences.enabledLanguages().changes(), + animeExtensionManager.installedExtensionsFlow, + animeExtensionManager.untrustedExtensionsFlow, + animeExtensionManager.availableExtensionsFlow, + ) { enabledLanguages, _installed, _untrusted, _available -> + val (updates, installed) = _installed + .filter { (showNsfwSources || !it.isNsfw) } + .sortedWith( + compareBy { !it.isObsolete } + .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, + ) + .partition { it.hasUpdate } + + val untrusted = _untrusted + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + + val available = _available + .filter { extension -> + _installed.none { it.pkgName == extension.pkgName } && + _untrusted.none { it.pkgName == extension.pkgName } && + (showNsfwSources || !extension.isNsfw) + } + .flatMap { ext -> + if (ext.sources.isEmpty()) { + return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList() + } + ext.sources.filter { it.lang in enabledLanguages } + .map { + ext.copy( + name = it.name, + lang = it.lang, + pkgName = "${ext.pkgName}-${it.id}", + sources = listOf(it), + ) + } + } + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + + AnimeExtensions(updates, installed, available, untrusted) + } + } +} diff --git a/app/src/main/java/eu/kanade/domain/animeextension/model/AnimeExtensions.kt b/app/src/main/java/eu/kanade/domain/animeextension/model/AnimeExtensions.kt new file mode 100644 index 0000000000..d685b9e39d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/animeextension/model/AnimeExtensions.kt @@ -0,0 +1,10 @@ +package eu.kanade.domain.animeextension.model + +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension + +data class AnimeExtensions( + val updates: List, + val installed: List, + val available: List, + val untrusted: List, +) diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index be21b31ef3..da8963811d 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -59,6 +59,8 @@ class SourcePreferences( fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) + fun animeExtensionUpdatesCount() = preferenceStore.getInt("anime_ext_updates_count", 0) + fun trustedExtensions() = preferenceStore.getStringSet( Preference.appStateKey("trusted_extensions"), emptySet(), diff --git a/app/src/main/java/eu/kanade/domain/ui/model/NavTabLayout.kt b/app/src/main/java/eu/kanade/domain/ui/model/NavTabLayout.kt index 75cfce1ee1..935b215e91 100644 --- a/app/src/main/java/eu/kanade/domain/ui/model/NavTabLayout.kt +++ b/app/src/main/java/eu/kanade/domain/ui/model/NavTabLayout.kt @@ -35,9 +35,10 @@ data class NavTabLayout( const val KEY_BROWSE = "Browse" const val KEY_DICTIONARY = "Dictionary" const val KEY_NOVELS = "Novels" + const val KEY_ANIME = "Anime" val ALL_KEYS = listOf( - KEY_LIBRARY, KEY_NOVELS, KEY_UPDATES, KEY_HISTORY, + KEY_LIBRARY, KEY_NOVELS, KEY_ANIME, KEY_UPDATES, KEY_HISTORY, KEY_BROWSE, KEY_DICTIONARY, ) diff --git a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt new file mode 100644 index 0000000000..ba8473192e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt @@ -0,0 +1,659 @@ +package eu.kanade.presentation.anime + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +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 +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.SmallExtendedFloatingActionButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.animateFloatingActionButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.foundation.clickable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastMap +import eu.kanade.presentation.anime.components.AnimeBottomActionMenu +import eu.kanade.presentation.anime.components.AnimeEpisodeListItem +import eu.kanade.presentation.anime.components.AnimeInfoHeader +import eu.kanade.presentation.anime.components.AnimeToolbar +import eu.kanade.presentation.anime.components.EpisodeHeader +import eu.kanade.tachiyomi.animesource.model.Video +import tachiyomi.core.common.preference.TriState +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.ui.anime.AnimeScreenModel +import eu.kanade.tachiyomi.ui.anime.EpisodeList +import tachiyomi.domain.episode.model.Episode +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.TwoPanelBox +import tachiyomi.presentation.core.components.VerticalFastScroller +import tachiyomi.presentation.core.components.material.PullRefresh +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.shouldExpandFAB + +@Composable +fun AnimeScreen( + state: AnimeScreenModel.State.Success, + snackbarHostState: SnackbarHostState, + isTabletUi: Boolean, + navigateUp: () -> Unit, + onEpisodeClicked: (Episode) -> Unit, + onContinueWatching: () -> Unit, + onAddToLibraryClicked: () -> Unit, + onTagSearch: (String) -> Unit, + onFilterButtonClicked: () -> Unit, + onRefresh: () -> Unit, + onShareClicked: (() -> Unit)? = null, + onEditCategoryClicked: (() -> Unit)? = null, + onCoverClicked: () -> Unit = {}, + onDownloadEpisode: (EpisodeList.Item) -> Unit = {}, + onDeleteEpisodeDownload: (EpisodeList.Item) -> Unit = {}, + onConfirmDownloadQuality: (Episode, Video) -> Unit = { _, _ -> }, + onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, + onMarkPreviousAsSeenClicked: (Episode) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onMultiDownloadClicked: (List) -> Unit = {}, + onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, + onUnseenFilterChanged: (TriState) -> Unit = {}, + onBookmarkedFilterChanged: (TriState) -> Unit = {}, + onSortModeChanged: (Long) -> Unit = {}, + onDisplayModeChanged: (Long) -> Unit = {}, + onDismissDialog: () -> Unit, + onDeleteClicked: () -> Unit = {}, + onConfirmDelete: () -> Unit = {}, + onAddToLibraryAnywayClicked: () -> Unit = {}, +) { + if (!isTabletUi) { + AnimeScreenSmallImpl( + state = state, + snackbarHostState = snackbarHostState, + navigateUp = navigateUp, + onEpisodeClicked = onEpisodeClicked, + onContinueWatching = onContinueWatching, + onAddToLibraryClicked = onAddToLibraryClicked, + onTagSearch = onTagSearch, + onFilterClicked = onFilterButtonClicked, + onRefresh = onRefresh, + onShareClicked = onShareClicked, + onEditCategoryClicked = onEditCategoryClicked, + onCoverClicked = onCoverClicked, + onDownloadEpisode = onDownloadEpisode, + onDeleteEpisodeDownload = onDeleteEpisodeDownload, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onMultiDeleteClicked = onMultiDeleteClicked, + onMultiDownloadClicked = onMultiDownloadClicked, + onEpisodeSelected = onEpisodeSelected, + onAllEpisodeSelected = onAllEpisodeSelected, + onInvertSelection = onInvertSelection, + ) + } else { + AnimeScreenLargeImpl( + state = state, + snackbarHostState = snackbarHostState, + navigateUp = navigateUp, + onEpisodeClicked = onEpisodeClicked, + onContinueWatching = onContinueWatching, + onAddToLibraryClicked = onAddToLibraryClicked, + onTagSearch = onTagSearch, + onFilterClicked = onFilterButtonClicked, + onRefresh = onRefresh, + onShareClicked = onShareClicked, + onEditCategoryClicked = onEditCategoryClicked, + onCoverClicked = onCoverClicked, + onDownloadEpisode = onDownloadEpisode, + onDeleteEpisodeDownload = onDeleteEpisodeDownload, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onMultiDeleteClicked = onMultiDeleteClicked, + onMultiDownloadClicked = onMultiDownloadClicked, + onEpisodeSelected = onEpisodeSelected, + onAllEpisodeSelected = onAllEpisodeSelected, + onInvertSelection = onInvertSelection, + ) + } + + // Dialogs + when (val dialog = state.dialog) { + is AnimeScreenModel.Dialog.SettingsSheet -> { + EpisodeSettingsDialog( + onDismissRequest = onDismissDialog, + anime = state.anime, + onUnseenFilterChanged = onUnseenFilterChanged, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + onSortModeChanged = onSortModeChanged, + onDisplayModeChanged = onDisplayModeChanged, + ) + } + is AnimeScreenModel.Dialog.ConfirmDelete -> { + AlertDialog( + onDismissRequest = onDismissDialog, + title = { Text(stringResource(MR.strings.action_delete)) }, + text = { Text(stringResource(MR.strings.anime_delete_confirm)) }, + confirmButton = { + TextButton(onClick = onConfirmDelete) { + Text(stringResource(MR.strings.action_delete)) + } + }, + dismissButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + ) + } + is AnimeScreenModel.Dialog.DownloadLoading -> { + AlertDialog( + onDismissRequest = onDismissDialog, + title = { Text("Resolving videos...") }, + text = { CircularProgressIndicator() }, + confirmButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + ) + } + is AnimeScreenModel.Dialog.QualitySelection -> { + AlertDialog( + onDismissRequest = onDismissDialog, + title = { Text("Select quality") }, + text = { + LazyColumn { + items( + items = dialog.videos, + key = { it.videoUrl }, + ) { video -> + ListItem( + headlineContent = { + Text( + text = video.videoTitle.ifBlank { + video.resolution?.let { "${it}p" } ?: "Unknown" + }, + ) + }, + modifier = Modifier.clickable { + onConfirmDownloadQuality(dialog.episode, video) + }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + ) + } + is AnimeScreenModel.Dialog.DuplicateAnime -> { + AlertDialog( + onDismissRequest = onDismissDialog, + title = { Text("Duplicate in library") }, + text = { + Text( + "An anime with the same title already exists in your library: " + + dialog.duplicates.joinToString { it.title }, + ) + }, + confirmButton = { + TextButton(onClick = { + onDismissDialog() + onAddToLibraryAnywayClicked() + }) { + Text("Add anyway") + } + }, + dismissButton = { + TextButton(onClick = onDismissDialog) { + Text(stringResource(MR.strings.action_cancel)) + } + }, + ) + } + null -> {} + } +} + +@Composable +private fun AnimeScreenSmallImpl( + state: AnimeScreenModel.State.Success, + snackbarHostState: SnackbarHostState, + navigateUp: () -> Unit, + onEpisodeClicked: (Episode) -> Unit, + onContinueWatching: () -> Unit, + onAddToLibraryClicked: () -> Unit, + onTagSearch: (String) -> Unit, + onFilterClicked: () -> Unit, + onRefresh: () -> Unit, + onShareClicked: (() -> Unit)?, + onEditCategoryClicked: (() -> Unit)?, + onCoverClicked: () -> Unit, + onDownloadEpisode: (EpisodeList.Item) -> Unit, + onDeleteEpisodeDownload: (EpisodeList.Item) -> Unit, + onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, + onMarkPreviousAsSeenClicked: (Episode) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onMultiDownloadClicked: (List) -> Unit, + onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, +) { + val episodeListState = rememberLazyListState() + + val (episodes, isAnySelected) = remember(state) { + Pair(state.episodes, state.isAnySelected) + } + + BackHandler(onBack = { + if (isAnySelected) { + onAllEpisodeSelected(false) + } else { + navigateUp() + } + }) + + Scaffold( + topBar = { + val selectedEpisodeCount = 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, + onClickRefresh = onRefresh, + onClickShare = onShareClicked, + onClickEditCategory = onEditCategoryClicked, + 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, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onMultiDeleteClicked = onMultiDeleteClicked, + onMultiDownloadClicked = onMultiDownloadClicked, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + val isFABVisible = remember(episodes) { + episodes.fastAny { !it.episode.seen } && !isAnySelected + } + SmallExtendedFloatingActionButton( + 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(), + modifier = Modifier.animateFloatingActionButton( + visible = isFABVisible, + alignment = Alignment.BottomEnd, + ), + ) + }, + ) { 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), + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + state = episodeListState, + contentPadding = PaddingValues( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ), + ) { + item( + key = AnimeScreenItem.INFO_BOX, + contentType = AnimeScreenItem.INFO_BOX, + ) { + AnimeInfoHeader( + anime = state.anime, + appBarPadding = topPadding, + onFavoriteToggle = onAddToLibraryClicked, + onTagSearch = onTagSearch, + onCoverClick = onCoverClicked, + ) + } + + item( + key = AnimeScreenItem.EPISODE_HEADER, + contentType = AnimeScreenItem.EPISODE_HEADER, + ) { + EpisodeHeader( + enabled = !isAnySelected, + episodeCount = state.allEpisodeCount, + onClick = onFilterClicked, + ) + } + + sharedEpisodeItems( + episodes = episodes, + isAnySelected = isAnySelected, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onDeleteEpisodeDownload = onDeleteEpisodeDownload, + onEpisodeSelected = onEpisodeSelected, + ) + } + } + } + } +} + +@Composable +private fun AnimeScreenLargeImpl( + state: AnimeScreenModel.State.Success, + snackbarHostState: SnackbarHostState, + navigateUp: () -> Unit, + onEpisodeClicked: (Episode) -> Unit, + onContinueWatching: () -> Unit, + onAddToLibraryClicked: () -> Unit, + onTagSearch: (String) -> Unit, + onFilterClicked: () -> Unit, + onRefresh: () -> Unit, + onShareClicked: (() -> Unit)?, + onEditCategoryClicked: (() -> Unit)?, + onCoverClicked: () -> Unit, + onDownloadEpisode: (EpisodeList.Item) -> Unit, + onDeleteEpisodeDownload: (EpisodeList.Item) -> Unit, + onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, + onMarkPreviousAsSeenClicked: (Episode) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onMultiDownloadClicked: (List) -> Unit, + onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, + onAllEpisodeSelected: (Boolean) -> Unit, + onInvertSelection: () -> Unit, +) { + val episodeListState = rememberLazyListState() + + val (episodes, isAnySelected) = remember(state) { + Pair(state.episodes, state.isAnySelected) + } + + BackHandler(onBack = { + if (isAnySelected) { + onAllEpisodeSelected(false) + } else { + navigateUp() + } + }) + + Scaffold( + topBar = { + val selectedEpisodeCount = remember(episodes) { + episodes.count { it.selected } + } + AnimeToolbar( + title = state.anime.title, + hasFilters = state.filterActive, + navigateUp = navigateUp, + onClickFilter = onFilterClicked, + onClickRefresh = onRefresh, + onClickShare = onShareClicked, + onClickEditCategory = onEditCategoryClicked, + actionModeCounter = selectedEpisodeCount, + onCancelActionMode = { onAllEpisodeSelected(false) }, + onSelectAll = { onAllEpisodeSelected(true) }, + onInvertSelection = onInvertSelection, + titleAlphaProvider = { 1f }, + backgroundAlphaProvider = { 1f }, + ) + }, + bottomBar = { + val selectedEpisodes = remember(episodes) { + episodes.filter { it.selected } + } + SharedAnimeBottomActionMenu( + selected = selectedEpisodes, + onMultiBookmarkClicked = onMultiBookmarkClicked, + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onMultiDeleteClicked = onMultiDeleteClicked, + onMultiDownloadClicked = onMultiDownloadClicked, + fillFraction = 0.5f, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + val isFABVisible = remember(episodes) { + episodes.fastAny { !it.episode.seen } && !isAnySelected + } + SmallExtendedFloatingActionButton( + 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(), + modifier = Modifier.animateFloatingActionButton( + visible = isFABVisible, + alignment = Alignment.BottomEnd, + ), + ) + }, + ) { contentPadding -> + TwoPanelBox( + modifier = Modifier.padding( + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + startContent = { + AnimeInfoHeader( + anime = state.anime, + appBarPadding = contentPadding.calculateTopPadding(), + onFavoriteToggle = onAddToLibraryClicked, + onTagSearch = onTagSearch, + onCoverClick = onCoverClicked, + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = contentPadding.calculateBottomPadding()), + ) + }, + endContent = { + VerticalFastScroller( + listState = episodeListState, + topContentPadding = contentPadding.calculateTopPadding(), + ) { + LazyColumn( + modifier = Modifier.fillMaxHeight(), + state = episodeListState, + contentPadding = PaddingValues( + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ), + ) { + item( + key = AnimeScreenItem.EPISODE_HEADER, + contentType = AnimeScreenItem.EPISODE_HEADER, + ) { + EpisodeHeader( + enabled = !isAnySelected, + episodeCount = state.allEpisodeCount, + onClick = onFilterClicked, + ) + } + + sharedEpisodeItems( + episodes = episodes, + isAnySelected = isAnySelected, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onDeleteEpisodeDownload = onDeleteEpisodeDownload, + onEpisodeSelected = onEpisodeSelected, + ) + } + } + }, + ) + } +} + +@Composable +private fun SharedAnimeBottomActionMenu( + selected: List, + onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, + onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, + onMarkPreviousAsSeenClicked: (Episode) -> Unit, + onMultiDeleteClicked: (List) -> Unit, + onMultiDownloadClicked: (List) -> Unit = {}, + fillFraction: Float = 1f, + modifier: Modifier = Modifier, +) { + AnimeBottomActionMenu( + visible = selected.isNotEmpty(), + modifier = modifier.fillMaxWidth(fillFraction), + onBookmarkClicked = { + onMultiBookmarkClicked(selected.fastMap { it.episode }, true) + }.takeIf { selected.fastAny { !it.episode.bookmark } }, + onRemoveBookmarkClicked = { + onMultiBookmarkClicked(selected.fastMap { it.episode }, false) + }.takeIf { selected.fastAll { it.episode.bookmark } }, + onMarkAsSeenClicked = { + onMultiMarkAsSeenClicked(selected.fastMap { it.episode }, true) + }.takeIf { selected.fastAny { !it.episode.seen } }, + onMarkAsUnseenClicked = { + onMultiMarkAsSeenClicked(selected.fastMap { it.episode }, false) + }.takeIf { selected.fastAny { it.episode.seen || it.episode.lastSecondSeen > 0L } }, + onMarkPreviousAsSeenClicked = { + onMarkPreviousAsSeenClicked(selected[0].episode) + }.takeIf { selected.size == 1 }, + onDownloadClicked = { + onMultiDownloadClicked(selected.fastMap { it.episode }) + }.takeIf { + selected.fastAny { it.downloadState != AnimeDownload.State.DOWNLOADED } + }, + onDeleteClicked = { + onMultiDeleteClicked(selected.fastMap { it.episode }) + }.takeIf { + selected.fastAny { it.downloadState == AnimeDownload.State.DOWNLOADED } + }, + ) +} + +private fun LazyListScope.sharedEpisodeItems( + episodes: List, + isAnySelected: Boolean, + onEpisodeClicked: (Episode) -> Unit, + onDownloadEpisode: (EpisodeList.Item) -> Unit, + onDeleteEpisodeDownload: (EpisodeList.Item) -> Unit, + onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, +) { + items( + items = episodes, + key = { it.id }, + contentType = { AnimeScreenItem.EPISODE }, + ) { item -> + AnimeEpisodeListItem( + episode = item.episode, + downloadState = item.downloadState, + downloadProgress = item.downloadProgress, + selected = item.selected, + onClick = { + when { + isAnySelected -> onEpisodeSelected(item, !item.selected, true, false) + else -> onEpisodeClicked(item.episode) + } + }, + onLongClick = { + onEpisodeSelected(item, !item.selected, true, true) + }, + onDownloadClick = { onDownloadEpisode(item) }, + onDeleteDownloadClick = { onDeleteEpisodeDownload(item) }, + ) + HorizontalDivider() + } +} + +private enum class AnimeScreenItem { + INFO_BOX, + EPISODE_HEADER, + EPISODE, +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt new file mode 100644 index 0000000000..f7ae8d1af3 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt @@ -0,0 +1,124 @@ +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.runtime.Composable +import androidx.compose.ui.Modifier +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.presentation.core.components.RadioItem +import tachiyomi.presentation.core.components.SortItem +import tachiyomi.presentation.core.components.TriStateItem +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun EpisodeSettingsDialog( + onDismissRequest: () -> Unit, + anime: Anime? = null, + onUnseenFilterChanged: (TriState) -> Unit, + onBookmarkedFilterChanged: (TriState) -> Unit, + onSortModeChanged: (Long) -> Unit, + onDisplayModeChanged: (Long) -> Unit, +) { + TabbedDialog( + onDismissRequest = onDismissRequest, + tabTitles = persistentListOf( + stringResource(MR.strings.action_filter), + stringResource(MR.strings.action_sort), + stringResource(MR.strings.action_display), + ), + ) { page -> + Column( + modifier = Modifier + .padding(vertical = TabbedDialogPaddings.Vertical) + .verticalScroll(rememberScrollState()), + ) { + when (page) { + 0 -> { + FilterPage( + unseenFilter = anime?.unseenFilter ?: TriState.DISABLED, + onUnseenFilterChanged = onUnseenFilterChanged, + bookmarkedFilter = anime?.bookmarkedFilter ?: TriState.DISABLED, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + ) + } + 1 -> { + SortPage( + sortingMode = anime?.sorting ?: 0, + sortDescending = anime?.sortDescending() ?: false, + onItemSelected = onSortModeChanged, + ) + } + 2 -> { + DisplayPage( + displayMode = anime?.displayMode ?: 0, + onItemSelected = onDisplayModeChanged, + ) + } + } + } + } +} + +@Composable +private fun ColumnScope.FilterPage( + unseenFilter: TriState, + onUnseenFilterChanged: (TriState) -> Unit, + bookmarkedFilter: TriState, + onBookmarkedFilterChanged: (TriState) -> Unit, +) { + TriStateItem( + label = stringResource(MR.strings.action_filter_unread), + state = unseenFilter, + onClick = onUnseenFilterChanged, + ) + TriStateItem( + label = stringResource(MR.strings.action_filter_bookmarked), + state = bookmarkedFilter, + onClick = onBookmarkedFilterChanged, + ) +} + +@Composable +private fun ColumnScope.SortPage( + sortingMode: Long, + sortDescending: Boolean, + onItemSelected: (Long) -> Unit, +) { + listOf( + MR.strings.sort_by_source to Anime.EPISODE_SORTING_SOURCE, + MR.strings.sort_by_number to Anime.EPISODE_SORTING_NUMBER, + MR.strings.sort_by_upload_date to Anime.EPISODE_SORTING_UPLOAD_DATE, + MR.strings.action_sort_alpha to Anime.EPISODE_SORTING_ALPHABET, + ).map { (titleRes, mode) -> + SortItem( + label = stringResource(titleRes), + sortDescending = sortDescending.takeIf { sortingMode == mode }, + onClick = { onItemSelected(mode) }, + ) + } +} + +@Composable +private fun ColumnScope.DisplayPage( + displayMode: Long, + onItemSelected: (Long) -> Unit, +) { + listOf( + MR.strings.show_title to Anime.EPISODE_DISPLAY_NAME, + MR.strings.show_chapter_number to Anime.EPISODE_DISPLAY_NUMBER, + ).map { (titleRes, mode) -> + RadioItem( + label = stringResource(titleRes), + selected = displayMode == mode, + onClick = { onItemSelected(mode) }, + ) + } +} 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 new file mode 100644 index 0000000000..19aede18b8 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt @@ -0,0 +1,217 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.BookmarkAdd +import androidx.compose.material.icons.outlined.BookmarkRemove +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.DoneAll +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.RemoveDone +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.R +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import kotlin.time.Duration.Companion.seconds + +@Composable +fun AnimeBottomActionMenu( + visible: Boolean, + modifier: Modifier = Modifier, + onBookmarkClicked: (() -> Unit)? = null, + onRemoveBookmarkClicked: (() -> Unit)? = null, + onMarkAsSeenClicked: (() -> Unit)? = null, + onMarkAsUnseenClicked: (() -> Unit)? = null, + onMarkPreviousAsSeenClicked: (() -> Unit)? = null, + onDownloadClicked: (() -> Unit)? = null, + onDeleteClicked: (() -> Unit)? = null, +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom), + ) { + val scope = rememberCoroutineScope() + Surface( + modifier = modifier, + shape = MaterialTheme.shapes.large.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + val haptic = LocalHapticFeedback.current + val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false) } + var resetJob by remember { mutableStateOf(null) } + val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + confirm.indices.forEach { i -> confirm[i] = i == toConfirmIndex } + resetJob?.cancel() + resetJob = scope.launch { + delay(1.seconds) + if (isActive) confirm[toConfirmIndex] = false + } + } + Row( + modifier = Modifier + .padding( + WindowInsets.navigationBars + .only(WindowInsetsSides.Bottom) + .asPaddingValues(), + ) + .padding(horizontal = 8.dp, vertical = 12.dp), + ) { + if (onBookmarkClicked != null) { + Button( + title = stringResource(MR.strings.action_bookmark), + icon = Icons.Outlined.BookmarkAdd, + toConfirm = confirm[0], + onLongClick = { onLongClickItem(0) }, + onClick = onBookmarkClicked, + ) + } + if (onRemoveBookmarkClicked != null) { + Button( + title = stringResource(MR.strings.action_remove_bookmark), + icon = Icons.Outlined.BookmarkRemove, + toConfirm = confirm[1], + onLongClick = { onLongClickItem(1) }, + onClick = onRemoveBookmarkClicked, + ) + } + if (onMarkAsSeenClicked != null) { + Button( + title = stringResource(MR.strings.action_mark_as_read), + icon = Icons.Outlined.DoneAll, + toConfirm = confirm[2], + onLongClick = { onLongClickItem(2) }, + onClick = onMarkAsSeenClicked, + ) + } + if (onMarkAsUnseenClicked != null) { + Button( + title = stringResource(MR.strings.action_mark_as_unread), + icon = Icons.Outlined.RemoveDone, + toConfirm = confirm[3], + onLongClick = { onLongClickItem(3) }, + onClick = onMarkAsUnseenClicked, + ) + } + if (onMarkPreviousAsSeenClicked != null) { + Button( + title = stringResource(MR.strings.action_mark_previous_as_read), + icon = ImageVector.vectorResource(R.drawable.ic_done_prev_24dp), + toConfirm = confirm[4], + onLongClick = { onLongClickItem(4) }, + onClick = onMarkPreviousAsSeenClicked, + ) + } + if (onDownloadClicked != null) { + Button( + title = stringResource(MR.strings.action_download), + icon = Icons.Outlined.Download, + toConfirm = confirm[5], + onLongClick = { onLongClickItem(5) }, + onClick = onDownloadClicked, + ) + } + if (onDeleteClicked != null) { + Button( + title = stringResource(MR.strings.action_delete), + icon = Icons.Outlined.Delete, + toConfirm = confirm[6], + onLongClick = { onLongClickItem(6) }, + onClick = onDeleteClicked, + ) + } + } + } + } +} + +@Composable +private fun RowScope.Button( + title: String, + icon: ImageVector, + toConfirm: Boolean, + onLongClick: () -> Unit, + onClick: () -> Unit, +) { + val animatedWeight by animateFloatAsState( + targetValue = if (toConfirm) 2f else 1f, + label = "weight", + ) + Box( + modifier = Modifier + .size(48.dp) + .weight(animatedWeight) + .combinedClickable( + interactionSource = null, + indication = ripple(bounded = false), + onLongClick = onLongClick, + onClick = onClick, + ), + contentAlignment = Alignment.Center, + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon, + contentDescription = title, + ) + AnimatedVisibility( + visible = toConfirm, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + Text( + text = title, + overflow = TextOverflow.Visible, + maxLines = 1, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} 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 new file mode 100644 index 0000000000..2e6ab22f82 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt @@ -0,0 +1,183 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.ui.player.buildProgressString +import tachiyomi.domain.episode.model.Episode +import tachiyomi.presentation.core.components.material.DISABLED_ALPHA +import tachiyomi.presentation.core.util.selectedBackground + +@Composable +fun AnimeEpisodeListItem( + episode: Episode, + downloadState: AnimeDownload.State, + downloadProgress: Int, + selected: Boolean = false, + onClick: () -> Unit, + onLongClick: () -> Unit = {}, + onDownloadClick: () -> Unit, + onDeleteDownloadClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val textColor = if (episode.seen) { + MaterialTheme.colorScheme.onSurface.copy(alpha = DISABLED_ALPHA) + } else { + MaterialTheme.colorScheme.onSurface + } + + Row( + modifier = modifier + .fillMaxWidth() + .selectedBackground(selected) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (episode.bookmark) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = 4.dp), + ) + } + + CompositionLocalProvider(LocalContentColor provides textColor) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = episode.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + val progress = buildProgressString(episode.lastSecondSeen, episode.totalSeconds) + if (progress != null) { + Text( + text = progress, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (episode.seen) DISABLED_ALPHA else 1f, + ), + maxLines = 1, + ) + } + + if (!episode.seen && episode.lastSecondSeen > 0 && episode.totalSeconds > 0) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { + (episode.lastSecondSeen.toFloat() / episode.totalSeconds.toFloat()) + .coerceIn(0f, 1f) + }, + modifier = Modifier + .fillMaxWidth() + .height(2.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + + EpisodeDownloadIndicator( + state = downloadState, + progress = downloadProgress, + onDownloadClick = onDownloadClick, + onDeleteClick = onDeleteDownloadClick, + ) + } +} + +@Composable +private fun EpisodeDownloadIndicator( + state: AnimeDownload.State, + progress: Int, + onDownloadClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + when (state) { + AnimeDownload.State.NOT_DOWNLOADED -> { + IconButton(onClick = onDownloadClick) { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + } + } + AnimeDownload.State.QUEUE -> { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + } + AnimeDownload.State.DOWNLOADING -> { + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + AnimeDownload.State.DOWNLOADED -> { + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), + ) + } + } + AnimeDownload.State.ERROR -> { + IconButton(onClick = onDownloadClick) { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp), + ) + } + } + } +} 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 new file mode 100644 index 0000000000..3e85f22d2f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt @@ -0,0 +1,198 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import tachiyomi.domain.anime.model.Anime + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AnimeInfoHeader( + anime: Anime, + appBarPadding: Dp = 0.dp, + onFavoriteToggle: () -> Unit, + onTagSearch: (String) -> Unit = {}, + onCoverClick: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.fillMaxWidth()) { + val backgroundColor = MaterialTheme.colorScheme.background + val backdropGradientColors = remember(backgroundColor) { + listOf(Color.Transparent, backgroundColor) + } + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(anime.thumbnailUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + colors = backdropGradientColors, + startY = size.height / 2, + ), + ) + } + .background(MaterialTheme.colorScheme.surfaceTint.copy(alpha = 0.4f)) + .blur(7.dp) + .alpha(0.2f), + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = appBarPadding + 16.dp, end = 16.dp, bottom = 16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + if (anime.thumbnailUrl != null) { + AsyncImage( + model = anime.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(100.dp) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onCoverClick), + ) + } else { + Icon( + imageVector = Icons.Filled.PlayCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(100.dp) + .padding(16.dp), + ) + } + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = anime.title, + style = MaterialTheme.typography.titleLarge, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + + val author = anime.author + if (!author.isNullOrBlank()) { + Text( + text = author, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(8.dp)) + + IconButton(onClick = onFavoriteToggle) { + Icon( + imageVector = if (anime.favorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = null, + tint = if (anime.favorite) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + } + } + + val description = anime.description + if (!description.isNullOrBlank()) { + Spacer(Modifier.height(12.dp)) + ExpandableDescription(description) + } + + val genres = anime.genre + if (!genres.isNullOrEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + genres.forEach { genre -> + AssistChip( + onClick = { onTagSearch(genre) }, + label = { Text(genre, style = MaterialTheme.typography.labelSmall) }, + ) + } + } + } + } + } +} + +@Composable +private fun ExpandableDescription(description: String) { + var expanded by rememberSaveable { mutableStateOf(false) } + + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = if (expanded) Int.MAX_VALUE else 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .animateContentSize() + .clickable { expanded = !expanded }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeToolbar.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeToolbar.kt new file mode 100644 index 0000000000..5fbc5e3723 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeToolbar.kt @@ -0,0 +1,112 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.FlipToBack +import androidx.compose.material.icons.outlined.SelectAll +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.AppBarTitle +import kotlinx.collections.immutable.persistentListOf +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.theme.active + +@Composable +fun AnimeToolbar( + title: String, + hasFilters: Boolean, + navigateUp: () -> Unit, + onClickFilter: () -> Unit, + onClickRefresh: () -> Unit, + onClickShare: (() -> Unit)? = null, + onClickEditCategory: (() -> Unit)? = null, + + actionModeCounter: Int, + onCancelActionMode: () -> Unit, + onSelectAll: () -> Unit, + onInvertSelection: () -> Unit, + + titleAlphaProvider: () -> Float, + backgroundAlphaProvider: () -> Float, + modifier: Modifier = Modifier, +) { + val isActionMode = actionModeCounter > 0 + AppBar( + titleContent = { + if (isActionMode) { + AppBarTitle(actionModeCounter.toString()) + } else { + AppBarTitle(title, modifier = Modifier.alpha(titleAlphaProvider())) + } + }, + modifier = modifier, + backgroundColor = MaterialTheme.colorScheme + .surfaceColorAtElevation(3.dp) + .copy(alpha = if (isActionMode) 1f else backgroundAlphaProvider()), + navigateUp = navigateUp, + actions = { + val filterTint = if (hasFilters) MaterialTheme.colorScheme.active else LocalContentColor.current + AppBarActions( + actions = persistentListOf().builder().apply { + if (isActionMode) { + add( + AppBar.Action( + title = stringResource(MR.strings.action_select_all), + icon = Icons.Outlined.SelectAll, + onClick = onSelectAll, + ), + ) + add( + AppBar.Action( + title = stringResource(MR.strings.action_select_inverse), + icon = Icons.Outlined.FlipToBack, + onClick = onInvertSelection, + ), + ) + return@apply + } + add( + AppBar.Action( + title = stringResource(MR.strings.action_filter), + icon = Icons.Outlined.FilterList, + iconTint = filterTint, + onClick = onClickFilter, + ), + ) + add( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_webview_refresh), + onClick = onClickRefresh, + ), + ) + if (onClickEditCategory != null) { + add( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_edit_categories), + onClick = onClickEditCategory, + ), + ) + } + if (onClickShare != null) { + add( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_share), + onClick = onClickShare, + ), + ) + } + }.build(), + ) + }, + isActionMode = isActionMode, + onCancelActionMode = onCancelActionMode, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt new file mode 100644 index 0000000000..322933f59a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt @@ -0,0 +1,45 @@ +package eu.kanade.presentation.anime.components + +import androidx.compose.foundation.clickable +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun EpisodeHeader( + enabled: Boolean, + episodeCount: Int?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable( + enabled = enabled, + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (episodeCount == null) { + stringResource(MR.strings.label_anime) + } else { + stringResource(MR.strings.anime_episode_count, episodeCount) + }, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryContent.kt b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryContent.kt new file mode 100644 index 0000000000..eabba1b0e7 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryContent.kt @@ -0,0 +1,106 @@ +package eu.kanade.presentation.anime.library + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalLayoutDirection +import eu.kanade.tachiyomi.ui.anime.library.AnimeLibraryItem +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import tachiyomi.domain.category.model.AnimeCategory +import tachiyomi.domain.library.model.LibraryAnime +import tachiyomi.domain.library.model.LibraryDisplayMode +import tachiyomi.presentation.core.components.material.PullRefresh +import kotlin.time.Duration.Companion.seconds + +@Composable +fun AnimeLibraryContent( + categories: List, + currentPage: Int, + contentPadding: PaddingValues, + selection: Set, + hasActiveFilters: Boolean, + showPageTabs: Boolean, + showAnimeCount: Boolean, + displayMode: LibraryDisplayMode, + onChangeCurrentPage: (Int) -> Unit, + onAnimeClicked: (Long) -> Unit, + onContinueWatchingClicked: ((LibraryAnime) -> Unit)?, + onToggleSelection: (LibraryAnime) -> Unit, + onRefresh: () -> Boolean, + getItemsForCategory: (AnimeCategory) -> List, +) { + Column( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), + end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), + ), + ) { + val pagerState = rememberPagerState(currentPage) { categories.size } + + val scope = rememberCoroutineScope() + var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } + + if (showPageTabs && categories.size > 1) { + AnimeLibraryTabs( + categories = categories, + pagerState = pagerState, + showAnimeCount = showAnimeCount, + getItemCount = { category -> getItemsForCategory(category).size }, + ) + } + + LaunchedEffect(pagerState.currentPage) { + onChangeCurrentPage(pagerState.currentPage) + } + + PullRefresh( + refreshing = isRefreshing, + onRefresh = { + val shouldRefresh = onRefresh() + if (shouldRefresh) { + isRefreshing = true + scope.launch { + delay(1.5.seconds) + isRefreshing = false + } + } + }, + enabled = selection.isEmpty(), + ) { + HorizontalPager( + state = pagerState, + beyondViewportPageCount = 1, + ) { page -> + val category = categories.getOrNull(page) ?: return@HorizontalPager + val items = getItemsForCategory(category) + + AnimeLibraryGrid( + items = items, + displayMode = displayMode, + selection = selection, + contentPadding = PaddingValues( + bottom = contentPadding.calculateBottomPadding(), + ), + onAnimeClicked = onAnimeClicked, + onContinueWatchingClicked = onContinueWatchingClicked, + onToggleSelection = onToggleSelection, + hasActiveFilters = hasActiveFilters, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryGrid.kt b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryGrid.kt new file mode 100644 index 0000000000..897a588011 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryGrid.kt @@ -0,0 +1,239 @@ +package eu.kanade.presentation.anime.library + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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 +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import eu.kanade.tachiyomi.ui.anime.library.AnimeLibraryItem +import tachiyomi.domain.library.model.LibraryAnime +import tachiyomi.domain.library.model.LibraryDisplayMode +import tachiyomi.presentation.core.components.Badge +import tachiyomi.presentation.core.components.BadgeGroup +import tachiyomi.presentation.core.components.ScrollbarLazyColumn +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.util.selectedBackground + +@Composable +fun AnimeLibraryGrid( + items: List, + displayMode: LibraryDisplayMode, + selection: Set, + contentPadding: PaddingValues, + hasActiveFilters: Boolean, + onAnimeClicked: (Long) -> Unit, + onContinueWatchingClicked: ((LibraryAnime) -> Unit)?, + onToggleSelection: (LibraryAnime) -> Unit, +) { + if (items.isEmpty()) { + EmptyScreen( + modifier = Modifier.padding(contentPadding), + message = if (hasActiveFilters) "No anime matching filters" else "Your anime library is empty", + ) + return + } + + when (displayMode) { + LibraryDisplayMode.List -> { + AnimeLibraryList( + items = items, + selection = selection, + contentPadding = contentPadding, + onAnimeClicked = onAnimeClicked, + onToggleSelection = onToggleSelection, + ) + } + else -> { + val columns = when (displayMode) { + LibraryDisplayMode.CompactGrid -> GridCells.Adaptive(96.dp) + LibraryDisplayMode.ComfortableGrid, + LibraryDisplayMode.ComfortableGridPanorama, + -> GridCells.Adaptive(128.dp) + LibraryDisplayMode.CoverOnlyGrid -> GridCells.Adaptive(80.dp) + LibraryDisplayMode.List -> GridCells.Adaptive(96.dp) + } + + LazyVerticalGrid( + columns = columns, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxSize(), + ) { + items( + items = items, + key = { it.libraryAnime.id }, + ) { item -> + val isSelected = item.libraryAnime.id in selection + AnimeGridItem( + item = item, + isSelected = isSelected, + showTitle = displayMode != LibraryDisplayMode.CoverOnlyGrid, + onClick = { + if (selection.isNotEmpty()) { + onToggleSelection(item.libraryAnime) + } else { + onAnimeClicked(item.libraryAnime.id) + } + }, + onLongClick = { onToggleSelection(item.libraryAnime) }, + ) + } + } + } + } +} + +@Composable +private fun AnimeGridItem( + item: AnimeLibraryItem, + isSelected: Boolean, + showTitle: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + Box( + modifier = Modifier + .selectedBackground(isSelected) + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + .clip(RoundedCornerShape(4.dp)), + ) { + AsyncImage( + model = item.libraryAnime.anime.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .alpha(if (isSelected) 0.76f else 1f), + ) + + if (item.unseenCount > 0 || item.downloadCount > 0) { + BadgeGroup( + modifier = Modifier + .padding(4.dp) + .align(Alignment.TopStart), + ) { + if (item.downloadCount > 0) { + Badge( + text = item.downloadCount.toString(), + color = MaterialTheme.colorScheme.tertiary, + textColor = MaterialTheme.colorScheme.onTertiary, + ) + } + if (item.unseenCount > 0) { + Badge(text = item.unseenCount.toString()) + } + } + } + + if (showTitle) { + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .padding(4.dp), + ) { + Text( + text = item.libraryAnime.anime.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun AnimeLibraryList( + items: List, + selection: Set, + contentPadding: PaddingValues, + onAnimeClicked: (Long) -> Unit, + onToggleSelection: (LibraryAnime) -> Unit, +) { + ScrollbarLazyColumn( + contentPadding = contentPadding, + modifier = Modifier.fillMaxSize(), + ) { + items( + items = items, + key = { it.libraryAnime.id }, + ) { item -> + val isSelected = item.libraryAnime.id in selection + Row( + modifier = Modifier + .fillMaxWidth() + .selectedBackground(isSelected) + .combinedClickable( + onClick = { + if (selection.isNotEmpty()) { + onToggleSelection(item.libraryAnime) + } else { + onAnimeClicked(item.libraryAnime.id) + } + }, + onLongClick = { onToggleSelection(item.libraryAnime) }, + ) + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AsyncImage( + model = item.libraryAnime.anime.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .height(48.dp) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(4.dp)), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 12.dp), + ) { + Text( + text = item.libraryAnime.anime.title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (item.unseenCount > 0) { + BadgeGroup { + Badge(text = item.unseenCount.toString()) + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibrarySettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibrarySettingsDialog.kt new file mode 100644 index 0000000000..e493f146b9 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibrarySettingsDialog.kt @@ -0,0 +1,120 @@ +package eu.kanade.presentation.anime.library + +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.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +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.library.model.LibraryDisplayMode +import tachiyomi.domain.library.model.LibrarySort +import tachiyomi.domain.library.service.AnimeLibraryPreferences +import tachiyomi.presentation.core.components.SortItem +import tachiyomi.presentation.core.components.TriStateItem +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@Composable +fun AnimeLibrarySettingsDialog( + onDismissRequest: () -> Unit, + preferences: AnimeLibraryPreferences = Injekt.get(), +) { + TabbedDialog( + onDismissRequest = onDismissRequest, + tabTitles = persistentListOf("Filter", "Sort", "Display"), + ) { page -> + Column( + modifier = Modifier + .padding(vertical = TabbedDialogPaddings.Vertical) + .verticalScroll(rememberScrollState()), + ) { + when (page) { + 0 -> FilterTab(preferences) + 1 -> SortTab(preferences) + 2 -> DisplayTab(preferences) + } + } + } +} + +@Composable +private fun ColumnScope.FilterTab(preferences: AnimeLibraryPreferences) { + val filterUnseen by remember { preferences.filterUnseen().changes() }.collectAsState(preferences.filterUnseen().get()) + val filterStarted by remember { preferences.filterStarted().changes() }.collectAsState(preferences.filterStarted().get()) + val filterBookmarked by remember { preferences.filterBookmarked().changes() }.collectAsState(preferences.filterBookmarked().get()) + val filterCompleted by remember { preferences.filterCompleted().changes() }.collectAsState(preferences.filterCompleted().get()) + val filterDownloaded by remember { preferences.filterDownloaded().changes() }.collectAsState(preferences.filterDownloaded().get()) + val filterFillermarked by remember { preferences.filterFillermarked().changes() }.collectAsState(preferences.filterFillermarked().get()) + + TriStateItem(label = "Unseen", state = filterUnseen, onClick = { preferences.filterUnseen().set(it) }) + TriStateItem(label = "Started", state = filterStarted, onClick = { preferences.filterStarted().set(it) }) + TriStateItem(label = "Bookmarked", state = filterBookmarked, onClick = { preferences.filterBookmarked().set(it) }) + TriStateItem(label = "Completed", state = filterCompleted, onClick = { preferences.filterCompleted().set(it) }) + TriStateItem(label = "Downloaded", state = filterDownloaded, onClick = { preferences.filterDownloaded().set(it) }) + TriStateItem(label = "Fillermarked", state = filterFillermarked, onClick = { preferences.filterFillermarked().set(it) }) +} + +@Composable +private fun ColumnScope.SortTab(preferences: AnimeLibraryPreferences) { + val sortMode by remember { preferences.sortingMode().changes() }.collectAsState(preferences.sortingMode().get()) + + val sortTypes = listOf( + LibrarySort.Type.Alphabetical to "Alphabetical", + LibrarySort.Type.LastRead to "Last watched", + LibrarySort.Type.LastUpdate to "Last update", + LibrarySort.Type.UnreadCount to "Unseen count", + LibrarySort.Type.TotalChapters to "Total episodes", + LibrarySort.Type.LatestChapter to "Latest episode", + LibrarySort.Type.ChapterFetchDate to "Episode fetch date", + LibrarySort.Type.DateAdded to "Date added", + ) + + sortTypes.forEach { (type, label) -> + val sortDescending = if (sortMode.type == type) !sortMode.isAscending else null + SortItem( + label = label, + sortDescending = sortDescending, + onClick = { + val newDirection = if (sortMode.type == type) { + if (sortMode.isAscending) LibrarySort.Direction.Descending + else LibrarySort.Direction.Ascending + } else { + LibrarySort.Direction.Ascending + } + preferences.sortingMode().set(LibrarySort(type, newDirection)) + }, + ) + } +} + +@Composable +private fun ColumnScope.DisplayTab(preferences: AnimeLibraryPreferences) { + val displayMode by remember { preferences.displayMode().changes() }.collectAsState(preferences.displayMode().get()) + + val modes = listOf( + LibraryDisplayMode.CompactGrid to "Compact grid", + LibraryDisplayMode.ComfortableGrid to "Comfortable grid", + LibraryDisplayMode.List to "List", + LibraryDisplayMode.CoverOnlyGrid to "Cover only", + ) + + modes.forEach { (mode, label) -> + FilterChip( + selected = displayMode == mode, + onClick = { preferences.displayMode().set(mode) }, + label = { Text(label) }, + modifier = Modifier.padding(horizontal = 4.dp), + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryTabs.kt b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryTabs.kt new file mode 100644 index 0000000000..b7d4168431 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/library/AnimeLibraryTabs.kt @@ -0,0 +1,49 @@ +package eu.kanade.presentation.anime.library + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import tachiyomi.domain.category.model.AnimeCategory + +@Composable +fun AnimeLibraryTabs( + categories: List, + pagerState: PagerState, + showAnimeCount: Boolean, + getItemCount: (AnimeCategory) -> Int, +) { + val scope = rememberCoroutineScope() + + PrimaryScrollableTabRow( + selectedTabIndex = pagerState.currentPage, + edgePadding = 0.dp, + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { + Column { + Text( + text = if (category.isSystemCategory) "Default" else category.name, + style = MaterialTheme.typography.bodyMedium, + ) + if (showAnimeCount) { + Text( + text = "${getItemCount(category)}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + ) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt new file mode 100644 index 0000000000..96f5cc532b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionDetailsScreen.kt @@ -0,0 +1,486 @@ +package eu.kanade.presentation.browse + +import android.content.Intent +import android.net.Uri +import android.provider.Settings +import android.util.DisplayMetrics +import androidx.compose.animation.graphics.res.animatedVectorResource +import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter +import androidx.compose.animation.graphics.vector.AnimatedImageVector +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Launch +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import eu.kanade.domain.animeextension.interactor.AnimeExtensionSourceItem +import eu.kanade.presentation.browse.components.AnimeExtensionIcon +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.components.AppBarActions +import eu.kanade.presentation.components.WarningBanner +import eu.kanade.presentation.more.settings.widget.TextPreferenceWidget +import eu.kanade.presentation.more.settings.widget.TrailingWidgetBuffer +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionDetailsScreenModel +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.copyToClipboard +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.ScrollbarLazyColumn +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen + +@Composable +fun AnimeExtensionDetailsScreen( + navigateUp: () -> Unit, + state: AnimeExtensionDetailsScreenModel.State, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onOpenWebView: (() -> Unit)?, + onClickEnableAll: () -> Unit, + onClickDisableAll: () -> Unit, + onClickClearCookies: () -> Unit, + onClickUninstall: () -> Unit, + onClickSource: (sourceId: Long) -> Unit, + onClickIncognito: (Boolean) -> Unit, +) { + val uriHandler = LocalUriHandler.current + val url = remember(state.extension) { + val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex() + regex.find(state.extension?.repoUrl.orEmpty()) + ?.let { + val (user, repo) = it.destructured + "https://github.com/$user/$repo" + } + ?: state.extension?.repoUrl + } + + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(MR.strings.label_extension_info), + navigateUp = navigateUp, + actions = { + AppBarActions( + actions = persistentListOf().builder() + .apply { + if (onOpenWebView != null) { + add( + AppBar.Action( + title = stringResource(MR.strings.action_open_in_web_view), + icon = Icons.Outlined.Public, + onClick = onOpenWebView, + ), + ) + } + if (url != null) { + add( + AppBar.Action( + title = stringResource(MR.strings.action_open_repo), + icon = Icons.AutoMirrored.Outlined.Launch, + onClick = { + uriHandler.openUri(url) + }, + ), + ) + } + addAll( + listOf( + AppBar.OverflowAction( + title = stringResource(MR.strings.action_enable_all), + onClick = onClickEnableAll, + ), + AppBar.OverflowAction( + title = stringResource(MR.strings.action_disable_all), + onClick = onClickDisableAll, + ), + AppBar.OverflowAction( + title = stringResource(MR.strings.pref_clear_cookies), + onClick = onClickClearCookies, + ), + ), + ) + } + .build(), + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + ) { paddingValues -> + if (state.extension == null) { + EmptyScreen( + MR.strings.empty_screen, + modifier = Modifier.padding(paddingValues), + ) + return@Scaffold + } + + AnimeExtensionDetails( + contentPadding = paddingValues, + extension = state.extension, + sources = state.sources, + incognitoMode = state.isIncognito, + onClickSourcePreferences = onClickSourcePreferences, + onClickUninstall = onClickUninstall, + onClickSource = onClickSource, + onClickIncognito = onClickIncognito, + ) + } +} + +@Composable +private fun AnimeExtensionDetails( + contentPadding: PaddingValues, + extension: AnimeExtension.Installed, + sources: ImmutableList, + incognitoMode: Boolean, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickUninstall: () -> Unit, + onClickSource: (sourceId: Long) -> Unit, + onClickIncognito: (Boolean) -> Unit, +) { + val context = LocalContext.current + var showNsfwWarning by remember { mutableStateOf(false) } + + ScrollbarLazyColumn( + contentPadding = contentPadding, + ) { + if (extension.isObsolete) { + item { + WarningBanner(MR.strings.obsolete_extension_message) + } + } + + item { + AnimeDetailsHeader( + extension = extension, + extIncognitoMode = incognitoMode, + onClickUninstall = onClickUninstall, + onClickAppInfo = { + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", extension.pkgName, null) + context.startActivity(this) + } + Unit + }.takeIf { extension.isShared }, + onClickAgeRating = { + showNsfwWarning = true + }, + onExtIncognitoChange = onClickIncognito, + ) + } + + items( + items = sources, + key = { "anime-extension-details-${it.source.id}" }, + ) { source -> + AnimeSourceSwitchPreference( + modifier = Modifier.animateItem(), + source = source, + onClickSourcePreferences = onClickSourcePreferences, + onClickSource = onClickSource, + ) + } + } + if (showNsfwWarning) { + AnimeNsfwWarningDialog( + onClickConfirm = { + showNsfwWarning = false + }, + ) + } +} + +@Composable +private fun AnimeDetailsHeader( + extension: AnimeExtension, + extIncognitoMode: Boolean, + onClickAgeRating: () -> Unit, + onClickUninstall: () -> Unit, + onClickAppInfo: (() -> Unit)?, + onExtIncognitoChange: (Boolean) -> Unit, +) { + val context = LocalContext.current + + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = MaterialTheme.padding.medium) + .padding( + top = MaterialTheme.padding.medium, + bottom = MaterialTheme.padding.small, + ) + .clickable { + val extDebugInfo = buildString { + append( + """ + Extension name: ${extension.name} (lang: ${extension.lang}; package: ${extension.pkgName}) + Extension version: ${extension.versionName} (lib: ${extension.libVersion}; version code: ${extension.versionCode}) + NSFW: ${extension.isNsfw} + """.trimIndent(), + ) + + if (extension is AnimeExtension.Installed) { + append("\n\n") + append( + """ + Update available: ${extension.hasUpdate} + Obsolete: ${extension.isObsolete} + Shared: ${extension.isShared} + Repository: ${extension.repoUrl} + """.trimIndent(), + ) + } + } + context.copyToClipboard("Extension Debug information", extDebugInfo) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimeExtensionIcon( + modifier = Modifier + .size(112.dp), + extension = extension, + density = DisplayMetrics.DENSITY_XXXHIGH, + ) + + Text( + text = extension.name, + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + + val strippedPkgName = extension.pkgName.substringAfter("eu.kanade.tachiyomi.animeextension.") + + Text( + text = strippedPkgName, + style = MaterialTheme.typography.bodySmall, + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = MaterialTheme.padding.extraLarge, + vertical = MaterialTheme.padding.small, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimeInfoText( + modifier = Modifier.weight(1f), + primaryText = extension.versionName, + secondaryText = stringResource(MR.strings.ext_info_version), + ) + + AnimeInfoDivider() + + AnimeInfoText( + modifier = Modifier.weight(if (extension.isNsfw) 1.5f else 1f), + primaryText = LocaleHelper.getSourceDisplayName(extension.lang, context), + secondaryText = stringResource(MR.strings.ext_info_language), + ) + + if (extension.isNsfw) { + AnimeInfoDivider() + + AnimeInfoText( + modifier = Modifier.weight(1f), + primaryText = stringResource(MR.strings.ext_nsfw_short), + primaryTextStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.Medium, + ), + secondaryText = stringResource(MR.strings.ext_info_age_rating), + onClick = onClickAgeRating, + ) + } + } + + Row( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .padding(top = MaterialTheme.padding.small), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.medium), + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = onClickUninstall, + ) { + Text(stringResource(MR.strings.ext_uninstall)) + } + + if (onClickAppInfo != null) { + Button( + modifier = Modifier.weight(1f), + onClick = onClickAppInfo, + ) { + Text( + text = stringResource(MR.strings.ext_app_info), + color = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + + TextPreferenceWidget( + modifier = Modifier.padding(horizontal = MaterialTheme.padding.small), + title = stringResource(MR.strings.pref_incognito_mode), + subtitle = stringResource(MR.strings.pref_incognito_mode_extension_summary), + icon = rememberAnimatedVectorPainter( + AnimatedImageVector.animatedVectorResource(R.drawable.anim_incognito), + extIncognitoMode, + ), + widget = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Switch( + checked = extIncognitoMode, + onCheckedChange = onExtIncognitoChange, + modifier = Modifier.padding(start = TrailingWidgetBuffer), + ) + } + }, + ) + + HorizontalDivider() + } +} + +@Composable +private fun AnimeInfoText( + primaryText: String, + secondaryText: String, + modifier: Modifier = Modifier, + primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge, + onClick: (() -> Unit)? = null, +) { + val clickableModifier = if (onClick != null) { + Modifier.clickable(interactionSource = null, indication = null, onClick = onClick) + } else { + Modifier + } + + Column( + modifier = modifier.then(clickableModifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = primaryText, + textAlign = TextAlign.Center, + style = primaryTextStyle, + ) + + Text( + text = secondaryText + if (onClick != null) " ⓘ" else "", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), + ) + } +} + +@Composable +private fun AnimeInfoDivider() { + VerticalDivider( + modifier = Modifier.height(20.dp), + ) +} + +@Composable +private fun AnimeSourceSwitchPreference( + source: AnimeExtensionSourceItem, + onClickSourcePreferences: (sourceId: Long) -> Unit, + onClickSource: (sourceId: Long) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + + TextPreferenceWidget( + modifier = modifier, + title = if (source.labelAsName) { + source.source.toString() + } else { + LocaleHelper.getSourceDisplayName(source.source.lang, context) + }, + widget = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (source.source is ConfigurableAnimeSource) { + IconButton(onClick = { onClickSourcePreferences(source.source.id) }) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(MR.strings.label_settings), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + Switch( + checked = source.enabled, + onCheckedChange = null, + modifier = Modifier.padding(start = TrailingWidgetBuffer), + ) + } + }, + onPreferenceClick = { onClickSource(source.source.id) }, + ) +} + +@Composable +private fun AnimeNsfwWarningDialog( + onClickConfirm: () -> Unit, +) { + AlertDialog( + text = { + Text(text = stringResource(MR.strings.ext_nsfw_warning)) + }, + confirmButton = { + TextButton(onClick = onClickConfirm) { + Text(text = stringResource(MR.strings.action_ok)) + } + }, + onDismissRequest = onClickConfirm, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionFilterScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionFilterScreen.kt new file mode 100644 index 0000000000..783bdaf854 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionFilterScreen.kt @@ -0,0 +1,99 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import eu.kanade.domain.extension.interactor.GetExtensionLanguages.Companion.getLanguageIconID +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.more.settings.widget.SwitchPreferenceWidget +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionFilterState +import eu.kanade.tachiyomi.util.system.LocaleHelper +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen + +@Composable +fun AnimeExtensionFilterScreen( + navigateUp: () -> Unit, + state: AnimeExtensionFilterState.Success, + onClickToggle: (String) -> Unit, +) { + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = stringResource(MR.strings.label_anime_extensions), + navigateUp = navigateUp, + scrollBehavior = scrollBehavior, + ) + }, + ) { contentPadding -> + if (state.isEmpty) { + EmptyScreen( + stringRes = MR.strings.empty_screen, + modifier = Modifier.padding(contentPadding), + ) + return@Scaffold + } + AnimeExtensionFilterContent( + contentPadding = contentPadding, + state = state, + onClickLang = onClickToggle, + ) + } +} + +@Composable +private fun AnimeExtensionFilterContent( + contentPadding: PaddingValues, + state: AnimeExtensionFilterState.Success, + onClickLang: (String) -> Unit, +) { + val context = LocalContext.current + LazyColumn( + contentPadding = contentPadding, + modifier = Modifier + .padding(start = MaterialTheme.padding.small), + ) { + items(state.languages) { language -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + val iconResId = getLanguageIconID(language) ?: R.drawable.globe + Icon( + painter = painterResource(id = iconResId), + tint = Color.Unspecified, + contentDescription = language, + modifier = Modifier + .width(48.dp) + .height(32.dp), + ) + SwitchPreferenceWidget( + modifier = Modifier.animateItem(), + title = LocaleHelper.getSourceDisplayName(language, context) + + ( + " (${LocaleHelper.getDisplayName(language)})" + .takeIf { language !in listOf("all", "other") } ?: "" + ), + checked = language in state.enabledLanguages, + onCheckedChanged = { onClickLang(language) }, + ) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionScreen.kt new file mode 100644 index 0000000000..7a85c289ab --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/AnimeExtensionScreen.kt @@ -0,0 +1,463 @@ +package eu.kanade.presentation.browse + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.GetApp +import androidx.compose.material.icons.outlined.Public +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.VerifiedUser +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import eu.kanade.presentation.browse.components.BaseBrowseItem +import eu.kanade.presentation.components.WarningBanner +import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText +import eu.kanade.presentation.util.animateItemFastScroll +import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionUiModel +import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionsScreenModel +import eu.kanade.tachiyomi.util.system.LocaleHelper +import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.FastScrollLazyColumn +import tachiyomi.presentation.core.components.material.PullRefresh +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.components.material.topSmallPaddingValues +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.screens.LoadingScreen +import tachiyomi.presentation.core.theme.header +import tachiyomi.presentation.core.util.plus +import tachiyomi.presentation.core.util.secondaryItemAlpha + +@Composable +fun AnimeExtensionScreen( + state: AnimeExtensionsScreenModel.State, + contentPadding: PaddingValues, + searchQuery: String?, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onOpenWebView: (AnimeExtension.Available) -> Unit, + onInstallExtension: (AnimeExtension.Available) -> Unit, + onUninstallExtension: (AnimeExtension) -> Unit, + onUpdateExtension: (AnimeExtension.Installed) -> Unit, + onTrustExtension: (AnimeExtension.Untrusted) -> Unit, + onOpenExtension: (AnimeExtension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, + onRefresh: () -> Unit, +) { + PullRefresh( + refreshing = state.isRefreshing, + onRefresh = onRefresh, + enabled = !state.isLoading, + ) { + when { + state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) + state.isEmpty -> { + val msg = if (!searchQuery.isNullOrEmpty()) { + MR.strings.no_results_found + } else { + MR.strings.empty_screen + } + EmptyScreen(msg, modifier = Modifier.padding(contentPadding)) + } + else -> { + AnimeExtensionContent( + state = state, + contentPadding = contentPadding, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onOpenWebView = onOpenWebView, + onInstallExtension = onInstallExtension, + onUninstallExtension = onUninstallExtension, + onUpdateExtension = onUpdateExtension, + onTrustExtension = onTrustExtension, + onOpenExtension = onOpenExtension, + onClickUpdateAll = onClickUpdateAll, + ) + } + } + } +} + +@Composable +private fun AnimeExtensionContent( + state: AnimeExtensionsScreenModel.State, + contentPadding: PaddingValues, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onOpenWebView: (AnimeExtension.Available) -> Unit, + onInstallExtension: (AnimeExtension.Available) -> Unit, + onUninstallExtension: (AnimeExtension) -> Unit, + onUpdateExtension: (AnimeExtension.Installed) -> Unit, + onTrustExtension: (AnimeExtension.Untrusted) -> Unit, + onOpenExtension: (AnimeExtension.Installed) -> Unit, + onClickUpdateAll: () -> Unit, +) { + val context = LocalContext.current + var trustState by remember { mutableStateOf(null) } + val installGranted = rememberRequestPackageInstallsPermissionState(initialValue = true) + + FastScrollLazyColumn( + contentPadding = contentPadding + topSmallPaddingValues, + ) { + if (!installGranted && state.installer?.requiresSystemPermission == true) { + item(key = "anime-extension-permissions-warning") { + WarningBanner( + textRes = MR.strings.ext_permission_install_apps_warning, + modifier = Modifier.clickable { + context.launchRequestPackageInstallsPermission() + }, + ) + } + } + + state.items.forEach { (header, items) -> + item( + contentType = "header", + key = "animeExtHeader-${header.hashCode()}", + ) { + when (header) { + is AnimeExtensionUiModel.Header.Resource -> { + val action: @Composable RowScope.() -> Unit = + if (header.textRes == MR.strings.ext_updates_pending) { + { + Button(onClick = { onClickUpdateAll() }) { + Text( + text = stringResource(MR.strings.ext_update_all), + style = LocalTextStyle.current.copy( + color = MaterialTheme.colorScheme.onPrimary, + ), + ) + } + } + } else { + {} + } + Row( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .animateItemFastScroll(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(header.textRes), + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f), + style = MaterialTheme.typography.header, + ) + action() + } + } + is AnimeExtensionUiModel.Header.Text -> { + Row( + modifier = Modifier + .padding(horizontal = MaterialTheme.padding.medium) + .animateItemFastScroll(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = header.text, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f), + style = MaterialTheme.typography.header, + ) + } + } + } + } + + items( + items = items, + contentType = { "item" }, + key = { item -> + when (item.extension) { + is AnimeExtension.Untrusted -> "anime-ext-untrusted-${item.hashCode()}" + is AnimeExtension.Installed -> "anime-ext-installed-${item.hashCode()}" + is AnimeExtension.Available -> "anime-ext-available-${item.hashCode()}" + } + }, + ) { item -> + AnimeExtensionItem( + modifier = Modifier.animateItemFastScroll(), + item = item, + onClickItem = { + when (it) { + is AnimeExtension.Available -> onInstallExtension(it) + is AnimeExtension.Installed -> onOpenExtension(it) + is AnimeExtension.Untrusted -> { trustState = it } + } + }, + onLongClickItem = onLongClickItem, + onClickItemCancel = onClickItemCancel, + onClickItemAction = { + when (it) { + is AnimeExtension.Available -> onInstallExtension(it) + is AnimeExtension.Installed -> { + if (it.hasUpdate) onUpdateExtension(it) else onOpenExtension(it) + } + is AnimeExtension.Untrusted -> { trustState = it } + } + }, + onClickItemSecondaryAction = { + when (it) { + is AnimeExtension.Available -> onOpenWebView(it) + is AnimeExtension.Installed -> onOpenExtension(it) + else -> {} + } + }, + ) + } + } + } + if (trustState != null) { + AlertDialog( + title = { Text(text = stringResource(MR.strings.untrusted_extension)) }, + text = { Text(text = stringResource(MR.strings.untrusted_extension_message)) }, + confirmButton = { + TextButton(onClick = { + onTrustExtension(trustState!!) + trustState = null + }) { Text(text = stringResource(MR.strings.ext_trust)) } + }, + dismissButton = { + TextButton(onClick = { + onUninstallExtension(trustState!!) + trustState = null + }) { Text(text = stringResource(MR.strings.ext_uninstall)) } + }, + onDismissRequest = { trustState = null }, + ) + } +} + +@Composable +private fun AnimeExtensionItem( + item: AnimeExtensionUiModel.Item, + onClickItem: (AnimeExtension) -> Unit, + onLongClickItem: (AnimeExtension) -> Unit, + onClickItemCancel: (AnimeExtension) -> Unit, + onClickItemAction: (AnimeExtension) -> Unit, + onClickItemSecondaryAction: (AnimeExtension) -> Unit, + modifier: Modifier = Modifier, +) { + val (extension, installStep) = item + BaseBrowseItem( + modifier = modifier + .combinedClickable( + onClick = { onClickItem(extension) }, + onLongClick = { onLongClickItem(extension) }, + ), + onClickItem = { onClickItem(extension) }, + onLongClickItem = { onLongClickItem(extension) }, + icon = { + Box( + modifier = Modifier.size(40.dp), + contentAlignment = Alignment.Center, + ) { + val idle = installStep.isCompleted() + if (!idle) { + CircularProgressIndicator( + modifier = Modifier.size(40.dp), + strokeWidth = 2.dp, + ) + } + + val padding by animateDpAsState(targetValue = if (idle) 0.dp else 8.dp) + when (extension) { + is AnimeExtension.Available -> { + AsyncImage( + model = extension.iconUrl, + contentDescription = null, + placeholder = ColorPainter(Color(0x1F888888)), + modifier = Modifier + .matchParentSize() + .padding(padding), + ) + } + is AnimeExtension.Installed -> { + AsyncImage( + model = extension.icon, + contentDescription = null, + placeholder = ColorPainter(Color(0x1F888888)), + modifier = Modifier + .matchParentSize() + .padding(padding), + ) + } + is AnimeExtension.Untrusted -> { + AsyncImage( + model = R.drawable.cover_error, + contentDescription = null, + modifier = Modifier + .matchParentSize() + .padding(padding), + ) + } + } + } + }, + action = { + AnimeExtensionItemActions( + extension = extension, + installStep = installStep, + onClickItemCancel = onClickItemCancel, + onClickItemAction = onClickItemAction, + onClickItemSecondaryAction = onClickItemSecondaryAction, + ) + }, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(start = MaterialTheme.padding.medium), + ) { + Text( + text = extension.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) + + FlowRow( + modifier = Modifier.secondaryItemAlpha(), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + ) { + ProvideTextStyle(value = MaterialTheme.typography.bodySmall) { + extension.lang?.let { + if (it.isNotEmpty()) { + Text(text = LocaleHelper.getSourceDisplayName(it, LocalContext.current)) + } + } + + if (extension.versionName.isNotEmpty()) { + DotSeparatorNoSpaceText() + Text(text = extension.versionName) + } + + val warning = when { + extension is AnimeExtension.Untrusted -> MR.strings.ext_untrusted + extension is AnimeExtension.Installed && extension.isObsolete -> MR.strings.ext_obsolete + extension.isNsfw -> MR.strings.ext_nsfw_short + else -> null + } + if (warning != null) { + DotSeparatorNoSpaceText() + Text( + text = stringResource(warning).uppercase(), + color = MaterialTheme.colorScheme.error, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!installStep.isCompleted()) { + DotSeparatorNoSpaceText() + Text( + text = when (installStep) { + InstallStep.Pending -> stringResource(MR.strings.ext_pending) + InstallStep.Downloading -> stringResource(MR.strings.ext_downloading) + InstallStep.Installing -> stringResource(MR.strings.ext_installing) + else -> error("Must not show non-install process text") + }, + ) + } + } + } + } + } +} + +@Composable +private fun AnimeExtensionItemActions( + extension: AnimeExtension, + installStep: InstallStep, + onClickItemCancel: (AnimeExtension) -> Unit, + onClickItemAction: (AnimeExtension) -> Unit, + onClickItemSecondaryAction: (AnimeExtension) -> Unit, +) { + val isIdle = installStep.isCompleted() + + Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) { + when { + !isIdle -> { + IconButton(onClick = { onClickItemCancel(extension) }) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(MR.strings.action_cancel)) + } + } + installStep == InstallStep.Error -> { + IconButton(onClick = { onClickItemAction(extension) }) { + Icon(Icons.Outlined.Refresh, contentDescription = stringResource(MR.strings.action_retry)) + } + } + installStep == InstallStep.Idle -> { + when (extension) { + is AnimeExtension.Installed -> { + IconButton(onClick = { onClickItemSecondaryAction(extension) }) { + Icon(Icons.Outlined.Settings, contentDescription = stringResource(MR.strings.action_settings)) + } + if (extension.hasUpdate) { + IconButton(onClick = { onClickItemAction(extension) }) { + Icon(Icons.Outlined.GetApp, contentDescription = stringResource(MR.strings.ext_update)) + } + } + } + is AnimeExtension.Untrusted -> { + IconButton(onClick = { onClickItemAction(extension) }) { + Icon(Icons.Outlined.VerifiedUser, contentDescription = stringResource(MR.strings.ext_trust)) + } + } + is AnimeExtension.Available -> { + if (extension.sources.isNotEmpty()) { + IconButton(onClick = { onClickItemSecondaryAction(extension) }) { + Icon(Icons.Outlined.Public, contentDescription = stringResource(MR.strings.action_open_in_web_view)) + } + } + IconButton(onClick = { onClickItemAction(extension) }) { + Icon(Icons.Outlined.GetApp, contentDescription = stringResource(MR.strings.ext_install)) + } + } + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt index a010a9d4ac..543ad778bb 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseIcons.kt @@ -29,6 +29,8 @@ import coil3.compose.AsyncImage import eu.kanade.domain.source.model.icon import eu.kanade.presentation.util.rememberResourceBitmapPainter import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionLoader import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.util.ExtensionLoader import tachiyomi.core.common.util.lang.withIOContext @@ -141,6 +143,68 @@ private fun Extension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): St } } +@Composable +fun AnimeExtensionIcon( + extension: AnimeExtension, + modifier: Modifier = Modifier, + density: Int = DisplayMetrics.DENSITY_DEFAULT, +) { + when (extension) { + is AnimeExtension.Available -> { + AsyncImage( + model = extension.iconUrl, + contentDescription = null, + placeholder = ColorPainter(Color(0x1F888888)), + error = rememberResourceBitmapPainter(id = R.drawable.cover_error), + modifier = modifier + .clip(MaterialTheme.shapes.extraSmall), + ) + } + is AnimeExtension.Installed -> { + val icon by extension.getIcon(density) + when (icon) { + is Result.Loading -> Box(modifier = modifier) + is Result.Success -> Image( + bitmap = (icon as Result.Success).value, + contentDescription = null, + modifier = modifier, + ) + is Result.Error -> Image( + bitmap = ImageBitmap.imageResource(id = R.mipmap.ic_default_source), + contentDescription = null, + modifier = modifier, + ) + } + } + is AnimeExtension.Untrusted -> Image( + imageVector = Icons.Filled.Dangerous, + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error), + modifier = modifier.then(defaultModifier), + ) + } +} + +@Composable +private fun AnimeExtension.getIcon(density: Int = DisplayMetrics.DENSITY_DEFAULT): State> { + val context = LocalContext.current + return produceState>(initialValue = Result.Loading, this) { + withIOContext { + value = try { + val appInfo = AnimeExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! + val appResources = context.packageManager.getResourcesForApplication(appInfo) + Result.Success( + appResources.getDrawableForDensity(appInfo.icon, density, null)!! + .toBitmap() + .asImageBitmap(), + ) + } catch (e: Exception) { + Result.Error + } + } + } +} + sealed class Result { data object Loading : Result() data object Error : Result() diff --git a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt index 31cc40b8a8..f13ad9d878 100644 --- a/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.outlined.GetApp import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.QueryStats import androidx.compose.material.icons.outlined.Search @@ -84,6 +85,7 @@ fun MoreScreen( onClickBrowse: () -> Unit, onClickDictionary: () -> Unit, onClickNovels: () -> Unit, + onClickAnime: () -> Unit, // KMK --> onClickLibraryUpdateErrors: () -> Unit, // KMK <-- @@ -218,6 +220,11 @@ fun MoreScreen( icon = Icons.Outlined.Book, onPreferenceClick = onClickNovels, ) + NavTabLayout.KEY_ANIME -> TextPreferenceWidget( + title = stringResource(MR.strings.label_anime), + icon = Icons.Outlined.PlayCircle, + onPreferenceClick = onClickAnime, + ) } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt index 99e329c538..ac7c26d5e3 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAppearanceScreen.kt @@ -343,6 +343,7 @@ object SettingsAppearanceScreen : SearchableSettings { eu.kanade.domain.ui.model.NavTabLayout.KEY_BROWSE -> "Browse" eu.kanade.domain.ui.model.NavTabLayout.KEY_DICTIONARY -> "Dictionary" eu.kanade.domain.ui.model.NavTabLayout.KEY_NOVELS -> "Novels" + eu.kanade.domain.ui.model.NavTabLayout.KEY_ANIME -> "Anime" else -> key } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt index 1596b69d23..37bd46ae26 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMainScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.outlined.GetApp import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Link import androidx.compose.material.icons.outlined.MenuBook +import androidx.compose.material.icons.outlined.OndemandVideo import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Security @@ -202,6 +203,12 @@ object SettingsMainScreen : Screen() { icon = Icons.AutoMirrored.Outlined.ChromeReaderMode, screen = SettingsReaderScreen, ), + Item( + titleRes = MR.strings.pref_category_player, + subtitleRes = MR.strings.pref_player_summary, + icon = Icons.Outlined.OndemandVideo, + screen = SettingsPlayerScreen, + ), Item( titleRes = MR.strings.pref_category_downloads, subtitleRes = MR.strings.pref_downloads_summary, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt new file mode 100644 index 0000000000..780800a812 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsPlayerScreen.kt @@ -0,0 +1,481 @@ +package eu.kanade.presentation.more.settings.screen + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import eu.kanade.presentation.more.settings.Preference +import eu.kanade.tachiyomi.ui.player.Debanding +import eu.kanade.tachiyomi.ui.player.SingleActionGesture +import eu.kanade.tachiyomi.ui.player.setting.AdvancedPlayerPreferences +import eu.kanade.tachiyomi.ui.player.setting.AudioChannels +import eu.kanade.tachiyomi.ui.player.setting.AudioPreferences +import eu.kanade.tachiyomi.ui.player.setting.DecoderPreferences +import eu.kanade.tachiyomi.ui.player.setting.GesturePreferences +import eu.kanade.tachiyomi.ui.player.setting.PlayerOrientation +import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences +import eu.kanade.tachiyomi.ui.player.setting.SubtitlePreferences +import eu.kanade.tachiyomi.ui.player.setting.SubtitlesBorderStyle +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toImmutableMap +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.collectAsState +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@Suppress("unused") +object SettingsPlayerScreen : SearchableSettings { + private fun readResolve(): Any = SettingsPlayerScreen + + @ReadOnlyComposable + @Composable + override fun getTitleRes() = MR.strings.pref_category_player + + @Composable + override fun getPreferences(): List { + val playerPreferences = remember { Injekt.get() } + val gesturePreferences = remember { Injekt.get() } + val subtitlePreferences = remember { Injekt.get() } + val audioPreferences = remember { Injekt.get() } + val decoderPreferences = remember { Injekt.get() } + val advancedPlayerPreferences = remember { Injekt.get() } + + return listOf( + getGeneralGroup(playerPreferences), + getPipGroup(playerPreferences), + getExternalPlayerGroup(playerPreferences), + getAniSkipGroup(playerPreferences), + getGesturesGroup(gesturePreferences), + getSubtitlesGroup(subtitlePreferences), + getAudioGroup(audioPreferences), + getAdvancedGroup(decoderPreferences, advancedPlayerPreferences), + ) + } + + @Composable + private fun getGeneralGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + val progressInterval by playerPreferences.progressSaveIntervalSec().collectAsState() + val controlsDelay by playerPreferences.playerTimeToDisappear().collectAsState() + val doubleTapSeek by playerPreferences.doubleTapSeekLength().collectAsState() + val progressThreshold by playerPreferences.progressPreference().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_category_general), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + preference = playerPreferences.defaultPlayerOrientation(), + entries = PlayerOrientation.entries + .associate { it.flag to playerOrientationString(it) } + .toImmutableMap(), + title = stringResource(MR.strings.pref_player_orientation), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.keepScreenOn(), + title = stringResource(MR.strings.pref_player_keep_screen_on), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.playerFullscreen(), + title = stringResource(MR.strings.pref_player_fullscreen), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.autoplayEnabled(), + title = stringResource(MR.strings.pref_player_autoplay), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.showLoadingCircle(), + title = stringResource(MR.strings.pref_player_show_loading), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.showCurrentChapter(), + title = stringResource(MR.strings.pref_player_show_chapter), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.reduceMotion(), + title = stringResource(MR.strings.pref_player_reduce_motion), + subtitle = stringResource(MR.strings.pref_player_reduce_motion_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.rememberPlayerBrightness(), + title = stringResource(MR.strings.pref_player_remember_brightness), + subtitle = stringResource(MR.strings.pref_player_remember_brightness_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.rememberPlayerVolume(), + title = stringResource(MR.strings.pref_player_remember_volume), + subtitle = stringResource(MR.strings.pref_player_remember_volume_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.preserveWatchingPosition(), + title = stringResource(MR.strings.pref_player_preserve_position), + subtitle = stringResource(MR.strings.pref_player_preserve_position_summary), + ), + Preference.PreferenceItem.SliderPreference( + value = progressInterval, + valueRange = 1..30, + title = stringResource(MR.strings.pref_player_progress_interval), + valueString = "${progressInterval}s", + onValueChanged = { + playerPreferences.progressSaveIntervalSec().set(it) + }, + ), + Preference.PreferenceItem.SliderPreference( + value = controlsDelay / 1000, + valueRange = 1..10, + title = stringResource(MR.strings.pref_player_controls_hide_delay), + valueString = "${controlsDelay / 1000}s", + onValueChanged = { + playerPreferences.playerTimeToDisappear().set(it * 1000) + }, + ), + Preference.PreferenceItem.SliderPreference( + value = doubleTapSeek, + valueRange = 5..30, + title = stringResource(MR.strings.pref_player_double_tap_seek), + valueString = "${doubleTapSeek}s", + onValueChanged = { + playerPreferences.doubleTapSeekLength().set(it) + }, + ), + Preference.PreferenceItem.SliderPreference( + value = (progressThreshold * 100).toInt(), + valueRange = 50..100, + title = stringResource(MR.strings.pref_player_progress_threshold), + valueString = "${(progressThreshold * 100).toInt()}%", + onValueChanged = { + playerPreferences.progressPreference().set(it / 100f) + }, + ), + ), + ) + } + + @Composable + private fun getPipGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + val pipEnabled by playerPreferences.enablePip().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_pip), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.enablePip(), + title = stringResource(MR.strings.pref_player_enable_pip), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.pipOnExit(), + title = stringResource(MR.strings.pref_player_pip_on_exit), + subtitle = stringResource(MR.strings.pref_player_pip_on_exit_summary), + enabled = pipEnabled, + ), + ), + ) + } + + @Composable + private fun getExternalPlayerGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_external), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.alwaysUseExternalPlayer(), + title = stringResource(MR.strings.pref_player_always_external), + ), + Preference.PreferenceItem.EditTextPreference( + preference = playerPreferences.externalPlayerPreference(), + title = stringResource(MR.strings.pref_player_external_app), + ), + ), + ) + } + + @Composable + private fun getAniSkipGroup(playerPreferences: PlayerPreferences): Preference.PreferenceGroup { + val aniSkipEnabled by playerPreferences.aniSkipEnabled().collectAsState() + val skipIntroEnabled by playerPreferences.enableSkipIntro().collectAsState() + val autoSkipIntro by playerPreferences.autoSkipIntro().collectAsState() + val netflixStyleEnabled by playerPreferences.enableNetflixStyleIntroSkip().collectAsState() + val waitingTime by playerPreferences.waitingTimeIntroSkip().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_aniskip), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.aniSkipEnabled(), + title = stringResource(MR.strings.pref_player_aniskip_enabled), + subtitle = stringResource(MR.strings.pref_player_aniskip_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.enableSkipIntro(), + title = stringResource(MR.strings.pref_player_skip_intro), + enabled = aniSkipEnabled, + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.autoSkipIntro(), + title = stringResource(MR.strings.pref_player_auto_skip_intro), + enabled = aniSkipEnabled && skipIntroEnabled, + ), + Preference.PreferenceItem.SwitchPreference( + preference = playerPreferences.enableNetflixStyleIntroSkip(), + title = stringResource(MR.strings.pref_player_netflix_skip), + enabled = aniSkipEnabled && skipIntroEnabled && !autoSkipIntro, + ), + Preference.PreferenceItem.SliderPreference( + value = waitingTime, + valueRange = 1..15, + title = stringResource(MR.strings.pref_player_skip_wait_time), + valueString = "${waitingTime}s", + enabled = aniSkipEnabled && skipIntroEnabled && !autoSkipIntro && netflixStyleEnabled, + onValueChanged = { + playerPreferences.waitingTimeIntroSkip().set(it) + }, + ), + ), + ) + } + + @Composable + private fun getGesturesGroup(gesturePreferences: GesturePreferences): Preference.PreferenceGroup { + val volBrightEnabled by gesturePreferences.gestureVolumeBrightness().collectAsState() + val skipLength by gesturePreferences.skipLengthPreference().collectAsState() + + val gestureEntries = persistentMapOf( + SingleActionGesture.None to stringResource(MR.strings.pref_player_gesture_none), + SingleActionGesture.Seek to stringResource(MR.strings.pref_player_gesture_seek), + SingleActionGesture.PlayPause to stringResource(MR.strings.pref_player_gesture_play_pause), + ) + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_gestures), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = gesturePreferences.gestureVolumeBrightness(), + title = stringResource(MR.strings.pref_player_gesture_vol_bright), + subtitle = stringResource(MR.strings.pref_player_gesture_vol_bright_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = gesturePreferences.swapVolumeBrightness(), + title = stringResource(MR.strings.pref_player_swap_vol_bright), + subtitle = stringResource(MR.strings.pref_player_swap_vol_bright_summary), + enabled = volBrightEnabled, + ), + Preference.PreferenceItem.SwitchPreference( + preference = gesturePreferences.gestureHorizontalSeek(), + title = stringResource(MR.strings.pref_player_gesture_h_seek), + subtitle = stringResource(MR.strings.pref_player_gesture_h_seek_summary), + ), + Preference.PreferenceItem.SliderPreference( + value = skipLength, + valueRange = 3..30, + title = stringResource(MR.strings.pref_player_skip_length), + valueString = "${skipLength}s", + onValueChanged = { + gesturePreferences.skipLengthPreference().set(it) + }, + ), + Preference.PreferenceItem.SwitchPreference( + preference = gesturePreferences.playerSmoothSeek(), + title = stringResource(MR.strings.pref_player_smooth_seek), + subtitle = stringResource(MR.strings.pref_player_smooth_seek_summary), + ), + Preference.PreferenceItem.ListPreference( + preference = gesturePreferences.leftDoubleTapGesture(), + entries = gestureEntries, + title = stringResource(MR.strings.pref_player_left_double_tap), + ), + Preference.PreferenceItem.ListPreference( + preference = gesturePreferences.centerDoubleTapGesture(), + entries = gestureEntries, + title = stringResource(MR.strings.pref_player_center_double_tap), + ), + Preference.PreferenceItem.ListPreference( + preference = gesturePreferences.rightDoubleTapGesture(), + entries = gestureEntries, + title = stringResource(MR.strings.pref_player_right_double_tap), + ), + ), + ) + } + + @Composable + private fun getSubtitlesGroup(subtitlePreferences: SubtitlePreferences): Preference.PreferenceGroup { + val fontSize by subtitlePreferences.subtitleFontSize().collectAsState() + val borderSize by subtitlePreferences.subtitleBorderSize().collectAsState() + val shadowOffset by subtitlePreferences.shadowOffsetSubtitles().collectAsState() + val subtitlePos by subtitlePreferences.subtitlePos().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_subtitles), + preferenceItems = persistentListOf( + Preference.PreferenceItem.EditTextPreference( + preference = subtitlePreferences.preferredSubLanguages(), + title = stringResource(MR.strings.pref_player_preferred_sub_lang), + ), + Preference.PreferenceItem.EditTextPreference( + preference = subtitlePreferences.subtitleFont(), + title = stringResource(MR.strings.pref_player_subtitle_font), + ), + Preference.PreferenceItem.SliderPreference( + value = fontSize, + valueRange = 20..100, + title = stringResource(MR.strings.pref_player_subtitle_font_size), + valueString = "$fontSize", + onValueChanged = { + subtitlePreferences.subtitleFontSize().set(it) + }, + ), + Preference.PreferenceItem.SwitchPreference( + preference = subtitlePreferences.boldSubtitles(), + title = stringResource(MR.strings.pref_player_subtitle_bold), + ), + Preference.PreferenceItem.SwitchPreference( + preference = subtitlePreferences.italicSubtitles(), + title = stringResource(MR.strings.pref_player_subtitle_italic), + ), + Preference.PreferenceItem.ListPreference( + preference = subtitlePreferences.borderStyleSubtitles(), + entries = persistentMapOf( + SubtitlesBorderStyle.OutlineAndShadow to stringResource(MR.strings.pref_player_subtitle_outline), + SubtitlesBorderStyle.OpaqueBox to stringResource(MR.strings.pref_player_subtitle_opaque_box), + SubtitlesBorderStyle.BackgroundBox to stringResource(MR.strings.pref_player_subtitle_bg_box), + ), + title = stringResource(MR.strings.pref_player_subtitle_border_style), + ), + Preference.PreferenceItem.SliderPreference( + value = borderSize, + valueRange = 0..10, + title = stringResource(MR.strings.pref_player_subtitle_border_size), + valueString = "$borderSize", + onValueChanged = { + subtitlePreferences.subtitleBorderSize().set(it) + }, + ), + Preference.PreferenceItem.SliderPreference( + value = shadowOffset, + valueRange = 0..10, + title = stringResource(MR.strings.pref_player_subtitle_shadow_offset), + valueString = "$shadowOffset", + onValueChanged = { + subtitlePreferences.shadowOffsetSubtitles().set(it) + }, + ), + Preference.PreferenceItem.SliderPreference( + value = subtitlePos, + valueRange = 0..100, + title = stringResource(MR.strings.pref_player_subtitle_position), + valueString = "$subtitlePos", + onValueChanged = { + subtitlePreferences.subtitlePos().set(it) + }, + ), + Preference.PreferenceItem.SwitchPreference( + preference = subtitlePreferences.overrideSubsASS(), + title = stringResource(MR.strings.pref_player_subtitle_override_ass), + subtitle = stringResource(MR.strings.pref_player_subtitle_override_ass_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = subtitlePreferences.screenshotSubtitles(), + title = stringResource(MR.strings.pref_player_screenshot_subs), + ), + ), + ) + } + + @Composable + private fun getAudioGroup(audioPreferences: AudioPreferences): Preference.PreferenceGroup { + val volumeBoostCap by audioPreferences.volumeBoostCap().collectAsState() + + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_audio), + preferenceItems = persistentListOf( + Preference.PreferenceItem.EditTextPreference( + preference = audioPreferences.preferredAudioLanguages(), + title = stringResource(MR.strings.pref_player_preferred_audio_lang), + ), + Preference.PreferenceItem.SwitchPreference( + preference = audioPreferences.enablePitchCorrection(), + title = stringResource(MR.strings.pref_player_pitch_correction), + subtitle = stringResource(MR.strings.pref_player_pitch_correction_summary), + ), + Preference.PreferenceItem.ListPreference( + preference = audioPreferences.audioChannels(), + entries = persistentMapOf( + AudioChannels.Auto to stringResource(MR.strings.pref_player_audio_auto), + AudioChannels.AutoSafe to stringResource(MR.strings.pref_player_audio_auto_safe), + AudioChannels.Mono to stringResource(MR.strings.pref_player_audio_mono), + AudioChannels.Stereo to stringResource(MR.strings.pref_player_audio_stereo), + AudioChannels.ReverseStereo to stringResource(MR.strings.pref_player_audio_reverse_stereo), + ), + title = stringResource(MR.strings.pref_player_audio_channels), + ), + Preference.PreferenceItem.SliderPreference( + value = volumeBoostCap, + valueRange = 0..200, + title = stringResource(MR.strings.pref_player_volume_boost_cap), + valueString = "$volumeBoostCap%", + onValueChanged = { + audioPreferences.volumeBoostCap().set(it) + }, + ), + ), + ) + } + + @Composable + private fun getAdvancedGroup( + decoderPreferences: DecoderPreferences, + advancedPlayerPreferences: AdvancedPlayerPreferences, + ): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(MR.strings.pref_player_advanced), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = decoderPreferences.tryHWDecoding(), + title = stringResource(MR.strings.pref_player_hw_decoding), + subtitle = stringResource(MR.strings.pref_player_hw_decoding_summary), + ), + Preference.PreferenceItem.SwitchPreference( + preference = decoderPreferences.gpuNext(), + title = stringResource(MR.strings.pref_player_gpu_next), + subtitle = stringResource(MR.strings.pref_player_gpu_next_summary), + ), + Preference.PreferenceItem.ListPreference( + preference = decoderPreferences.videoDebanding(), + entries = persistentMapOf( + Debanding.None to stringResource(MR.strings.pref_player_debanding_none), + Debanding.CPU to stringResource(MR.strings.pref_player_debanding_cpu), + Debanding.GPU to stringResource(MR.strings.pref_player_debanding_gpu), + ), + title = stringResource(MR.strings.pref_player_debanding), + ), + Preference.PreferenceItem.SwitchPreference( + preference = decoderPreferences.useYUV420P(), + title = stringResource(MR.strings.pref_player_yuv420p), + subtitle = stringResource(MR.strings.pref_player_yuv420p_summary), + ), + Preference.PreferenceItem.EditTextPreference( + preference = advancedPlayerPreferences.mpvConf(), + title = stringResource(MR.strings.pref_player_mpv_conf), + subtitle = stringResource(MR.strings.pref_player_mpv_conf_summary), + ), + Preference.PreferenceItem.EditTextPreference( + preference = advancedPlayerPreferences.mpvInput(), + title = stringResource(MR.strings.pref_player_mpv_input), + subtitle = stringResource(MR.strings.pref_player_mpv_input_summary), + ), + ), + ) + } + + @Composable + private fun playerOrientationString(orientation: PlayerOrientation): String { + return when (orientation) { + PlayerOrientation.FREE -> stringResource(MR.strings.pref_player_orientation_free) + PlayerOrientation.PORTRAIT -> stringResource(MR.strings.pref_player_orientation_portrait) + PlayerOrientation.LANDSCAPE -> stringResource(MR.strings.pref_player_orientation_landscape) + PlayerOrientation.LOCKED_PORTRAIT -> stringResource(MR.strings.pref_player_orientation_locked_portrait) + PlayerOrientation.LOCKED_LANDSCAPE -> stringResource(MR.strings.pref_player_orientation_locked_landscape) + PlayerOrientation.REVERSE_PORTRAIT -> stringResource(MR.strings.pref_player_orientation_reverse_portrait) + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt index bf292a7dc0..391641eaa4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSearchScreen.kt @@ -289,6 +289,7 @@ private val settingScreens = listOf( SettingsAppearanceScreen, SettingsLibraryScreen, SettingsReaderScreen, + SettingsPlayerScreen, SettingsDownloadScreen, SettingsTrackingScreen, // AM (CONNECTIONS) --> diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/NavigationStyleScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/NavigationStyleScreen.kt index 7e9140aadc..24b98245f4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/NavigationStyleScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/appearance/NavigationStyleScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.DragHandle import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Public import androidx.compose.material.icons.outlined.Search import androidx.compose.material3.ElevatedCard @@ -77,6 +78,7 @@ class NavigationStyleScreen : Screen() { NavTabLayout.KEY_BROWSE to stringResource(MR.strings.browse), NavTabLayout.KEY_DICTIONARY to stringResource(MR.strings.label_dictionary), NavTabLayout.KEY_NOVELS to stringResource(MR.strings.label_novels), + NavTabLayout.KEY_ANIME to stringResource(MR.strings.label_anime), ) // Section titles @@ -212,6 +214,7 @@ private fun getTabIcon(key: String): ImageVector { NavTabLayout.KEY_BROWSE -> Icons.Outlined.Public NavTabLayout.KEY_DICTIONARY -> Icons.Outlined.Search NavTabLayout.KEY_NOVELS -> Icons.Outlined.Book + NavTabLayout.KEY_ANIME -> Icons.Outlined.PlayCircle else -> Icons.Outlined.Public } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreen.kt new file mode 100644 index 0000000000..39c169d751 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreen.kt @@ -0,0 +1,94 @@ +package eu.kanade.presentation.more.settings.screen.browse + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConfirmDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog +import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.util.system.openInBrowser +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.flow.collectLatest +import tachiyomi.presentation.core.screens.LoadingScreen + +class AnimeExtensionReposScreen : Screen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { AnimeExtensionReposScreenModel() } + val state by screenModel.state.collectAsState() + + if (state is AnimeRepoScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as AnimeRepoScreenState.Success + + ExtensionReposScreen( + state = RepoScreenState.Success( + repos = successState.repos, + ), + onClickCreate = { screenModel.showDialog(AnimeRepoDialog.Create) }, + onOpenWebsite = { context.openInBrowser(it.website) }, + onClickDelete = { screenModel.showDialog(AnimeRepoDialog.Delete(it)) }, + onClickEnable = {}, + onClickDisable = {}, + onClickRefresh = { screenModel.refreshRepos() }, + navigateUp = navigator::pop, + ) + + when (val dialog = successState.dialog) { + null -> {} + is AnimeRepoDialog.Create -> { + ExtensionRepoCreateDialog( + onDismissRequest = screenModel::dismissDialog, + onCreate = { screenModel.createRepo(it) }, + repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(), + ) + } + is AnimeRepoDialog.Delete -> { + ExtensionRepoDeleteDialog( + onDismissRequest = screenModel::dismissDialog, + onDelete = { screenModel.deleteRepo(dialog.repo) }, + repo = dialog.repo, + ) + } + is AnimeRepoDialog.Conflict -> { + ExtensionRepoConflictDialog( + onDismissRequest = screenModel::dismissDialog, + onMigrate = { screenModel.replaceRepo(dialog.newRepo) }, + oldRepo = dialog.oldRepo, + newRepo = dialog.newRepo, + ) + } + is AnimeRepoDialog.Confirm -> { + ExtensionRepoConfirmDialog( + onDismissRequest = screenModel::dismissDialog, + onCreate = { screenModel.createRepo(dialog.url) }, + repo = dialog.url, + ) + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + if (event is AnimeRepoEvent.LocalizedMessage) { + context.toast(event.stringRes) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreenModel.kt new file mode 100644 index 0000000000..ec81355b32 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/AnimeExtensionReposScreenModel.kt @@ -0,0 +1,129 @@ +package eu.kanade.presentation.more.settings.screen.browse + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import mihon.domain.animeextensionrepo.interactor.CreateAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.DeleteAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.GetAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.ReplaceAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.UpdateAnimeExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeExtensionReposScreenModel( + private val getAnimeExtensionRepo: GetAnimeExtensionRepo = Injekt.get(), + private val createAnimeExtensionRepo: CreateAnimeExtensionRepo = Injekt.get(), + private val deleteAnimeExtensionRepo: DeleteAnimeExtensionRepo = Injekt.get(), + private val replaceAnimeExtensionRepo: ReplaceAnimeExtensionRepo = Injekt.get(), + private val updateAnimeExtensionRepo: UpdateAnimeExtensionRepo = Injekt.get(), + private val animeExtensionManager: AnimeExtensionManager = Injekt.get(), +) : StateScreenModel(AnimeRepoScreenState.Loading) { + + private val _events: Channel = Channel(Int.MAX_VALUE) + val events = _events.receiveAsFlow() + + init { + screenModelScope.launchIO { + getAnimeExtensionRepo.subscribeAll() + .collectLatest { repos -> + mutableState.update { + AnimeRepoScreenState.Success(repos = repos.toImmutableSet()) + } + } + } + } + + fun createRepo(baseUrl: String) { + screenModelScope.launchIO { + when (val result = createAnimeExtensionRepo.await(baseUrl)) { + CreateAnimeExtensionRepo.Result.Success -> animeExtensionManager.findAvailableExtensions() + CreateAnimeExtensionRepo.Result.InvalidUrl -> _events.send(AnimeRepoEvent.InvalidUrl) + CreateAnimeExtensionRepo.Result.RepoAlreadyExists -> _events.send(AnimeRepoEvent.RepoAlreadyExists) + is CreateAnimeExtensionRepo.Result.DuplicateFingerprint -> { + showDialog(AnimeRepoDialog.Conflict(result.oldRepo, result.newRepo)) + } + else -> {} + } + } + } + + fun replaceRepo(newRepo: ExtensionRepo) { + screenModelScope.launchIO { + replaceAnimeExtensionRepo.await(newRepo) + } + } + + fun refreshRepos() { + val status = state.value + if (status is AnimeRepoScreenState.Success) { + screenModelScope.launchIO { + updateAnimeExtensionRepo.awaitAll() + } + } + } + + fun deleteRepo(baseUrl: String) { + screenModelScope.launchIO { + deleteAnimeExtensionRepo.await(baseUrl) + animeExtensionManager.findAvailableExtensions() + } + } + + fun showDialog(dialog: AnimeRepoDialog) { + mutableState.update { + when (it) { + AnimeRepoScreenState.Loading -> it + is AnimeRepoScreenState.Success -> it.copy(dialog = dialog) + } + } + } + + fun dismissDialog() { + mutableState.update { + when (it) { + AnimeRepoScreenState.Loading -> it + is AnimeRepoScreenState.Success -> it.copy(dialog = null) + } + } + } +} + +sealed class AnimeRepoEvent { + sealed class LocalizedMessage(val stringRes: StringResource) : AnimeRepoEvent() + data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name) + data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists) +} + +sealed class AnimeRepoDialog { + data object Create : AnimeRepoDialog() + data class Delete(val repo: String) : AnimeRepoDialog() + data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : AnimeRepoDialog() + data class Confirm(val url: String) : AnimeRepoDialog() +} + +sealed class AnimeRepoScreenState { + + @Immutable + data object Loading : AnimeRepoScreenState() + + @Immutable + data class Success( + val repos: ImmutableSet, + val dialog: AnimeRepoDialog? = null, + ) : AnimeRepoScreenState() { + val isEmpty: Boolean + get() = repos.isEmpty() + } +} 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 new file mode 100644 index 0000000000..a95f1df5ef --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/player/components/PlayerSheet.kt @@ -0,0 +1,233 @@ +package eu.kanade.presentation.player.components + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.animateTo +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.ZeroCornerSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +private val sheetAnimationSpec = tween(350) + +@Composable +fun PlayerSheet( + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, + tonalElevation: Dp = 1.dp, + dismissEvent: Boolean = false, + content: @Composable () -> Unit, +) { + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val latestOnDismissRequest by rememberUpdatedState(onDismissRequest) + val maxWidth = if (LocalConfiguration.current.orientation == ORIENTATION_LANDSCAPE) { + 640.dp + } else { + 420.dp + } + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * .95f + + var backgroundAlpha by remember { mutableFloatStateOf(0f) } + val alpha by animateFloatAsState( + backgroundAlpha, + animationSpec = sheetAnimationSpec, + label = "alpha", + ) + + val decayAnimationSpec = rememberSplineBasedDecay() + val anchoredDraggableState = remember { + AnchoredDraggableState( + initialValue = 1, + snapAnimationSpec = sheetAnimationSpec, + decayAnimationSpec = decayAnimationSpec, + positionalThreshold = { with(density) { 56.dp.toPx() } }, + velocityThreshold = { with(density) { 125.dp.toPx() } }, + ) + } + + LaunchedEffect(dismissEvent) { + if (dismissEvent) { + backgroundAlpha = 0f + anchoredDraggableState.animateTo(1) + onDismissRequest() + } + } + + val internalOnDismissRequest = { + if (anchoredDraggableState.currentValue == 0) { + scope.launch { + backgroundAlpha = 0f + anchoredDraggableState.animateTo(1) + } + } + } + Box( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = internalOnDismissRequest, + ) + .fillMaxSize() + .background(Color.Black.copy(alpha)) + .onSizeChanged { + val anchors = DraggableAnchors { + 0 at 0f + 1 at it.height.toFloat() + } + anchoredDraggableState.updateAnchors(anchors) + }, + contentAlignment = Alignment.BottomCenter, + ) { + Surface( + modifier = Modifier + .sizeIn(maxWidth = maxWidth, maxHeight = maxHeight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ) + .nestedScroll( + remember(anchoredDraggableState) { + anchoredDraggableState.preUpPostDownNestedScrollConnection() + }, + ) + .then(modifier) + .offset { + IntOffset( + 0, + anchoredDraggableState.offset + .takeIf { it.isFinite() } + ?.roundToInt() + ?: 0, + ) + } + .anchoredDraggable( + state = anchoredDraggableState, + orientation = Orientation.Vertical, + ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal), + ), + shape = MaterialTheme.shapes.extraLarge.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize), + tonalElevation = tonalElevation, + content = { + BackHandler( + enabled = anchoredDraggableState.targetValue == 0, + onBack = internalOnDismissRequest, + ) + content() + }, + ) + + LaunchedEffect(true) { + backgroundAlpha = 0.5f + } + + LaunchedEffect(anchoredDraggableState) { + scope.launch { anchoredDraggableState.animateTo(0) } + snapshotFlow { anchoredDraggableState.currentValue } + .drop(1) + .filter { it == 1 } + .collectLatest { latestOnDismissRequest() } + } + } +} + +private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection() = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val delta = available.toFloat() + return if (delta < 0 && source == NestedScrollSource.UserInput) { + dispatchRawDelta(delta).toOffset() + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + return if (source == NestedScrollSource.UserInput) { + dispatchRawDelta(available.toFloat()).toOffset() + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val toFling = available.toFloat() + return if (toFling < 0 && offset > anchors.minPosition()) { + settle(toFling) + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val toFling = available.toFloat() + return if (toFling > 0) { + settle(toFling) + available + } else { + Velocity.Zero + } + } + + private fun Float.toOffset(): Offset = Offset(0f, this) + + @JvmName("velocityToFloat") + private fun Velocity.toFloat() = y + + private fun Offset.toFloat(): Float = y +} 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 new file mode 100644 index 0000000000..4e9001030f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/player/components/SwitchPreference.kt @@ -0,0 +1,33 @@ +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.selection.toggleable +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 + +@Composable +fun SwitchPreference( + value: Boolean, + onValueChange: (Boolean) -> Unit, + content: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .toggleable(value, true, Role.Switch, null, onValueChange) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + content() + Switch( + checked = value, + onCheckedChange = null, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index e655ca4413..1a0d823dd0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -35,6 +35,7 @@ import com.elvishew.xlog.printer.Printer import com.elvishew.xlog.printer.file.backup.NeverBackupStrategy import com.elvishew.xlog.printer.file.naming.DateFileNameGenerator import dev.mihon.injekt.patchInjekt +import eu.kanade.domain.AnimeDomainModule import eu.kanade.domain.DomainModule import eu.kanade.domain.KMKDomainModule import eu.kanade.domain.SYDomainModule @@ -141,6 +142,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor Injekt.importModule(PreferenceModule(this)) Injekt.importModule(AppModule(this)) Injekt.importModule(DomainModule()) + Injekt.importModule(AnimeDomainModule()) // KMK --> Injekt.importModule(KMKDomainModule()) // KMK <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt new file mode 100644 index 0000000000..9844149db1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/AnimeExtensionManager.kt @@ -0,0 +1,263 @@ +package eu.kanade.tachiyomi.animeextension + +import android.content.Context +import android.graphics.drawable.Drawable +import eu.kanade.domain.extension.interactor.TrustExtension +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.api.AnimeExtensionApi +import eu.kanade.tachiyomi.animeextension.api.AnimeExtensionUpdateNotifier +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionInstallReceiver +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionInstaller +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionLoader +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import logcat.LogPriority +import tachiyomi.core.common.util.lang.withUIContext +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.animesource.model.StubAnimeSource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeExtensionManager( + private val context: Context, + private val preferences: SourcePreferences = Injekt.get(), + private val trustExtension: TrustExtension = Injekt.get(), +) { + + val scope = CoroutineScope(SupervisorJob()) + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val api = AnimeExtensionApi() + + private val installer by lazy { AnimeExtensionInstaller(context) } + + private val iconMap = mutableMapOf() + + private val installedExtensionMapFlow = MutableStateFlow(emptyMap()) + val installedExtensionsFlow = installedExtensionMapFlow.mapExtensions(scope) + + private val availableExtensionMapFlow = MutableStateFlow(emptyMap()) + val availableExtensionsFlow = availableExtensionMapFlow.map { it.values.toList() } + .stateIn(scope, SharingStarted.Lazily, availableExtensionMapFlow.value.values.toList()) + + private val untrustedExtensionMapFlow = MutableStateFlow(emptyMap()) + val untrustedExtensionsFlow = untrustedExtensionMapFlow.mapExtensions(scope) + + init { + initExtensions() + AnimeExtensionInstallReceiver(InstallationListener()).register(context) + } + + fun getExtensionPackage(sourceId: Long): String? { + return installedExtensionsFlow.value.find { extension -> + extension.sources.any { it.id == sourceId } + } + ?.pkgName + } + + fun getAppIconForSource(sourceId: Long): Drawable? { + val pkgName = getExtensionPackage(sourceId) ?: return null + return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { + AnimeExtensionLoader.getExtensionPackageInfoFromPkgName(context, pkgName)!!.applicationInfo!! + .loadIcon(context.packageManager) + } + } + + private var availableExtensionsSourcesData: Map = emptyMap() + + private fun setupAvailableExtensionsSourcesDataMap(extensions: List) { + if (extensions.isEmpty()) return + availableExtensionsSourcesData = extensions + .flatMap { ext -> ext.sources.map { it.toStubAnimeSource() } } + .associateBy { it.id } + } + + fun getSourceData(id: Long) = availableExtensionsSourcesData[id] + + private fun initExtensions() { + val extensions = AnimeExtensionLoader.loadExtensions(context) + + installedExtensionMapFlow.value = extensions + .filterIsInstance() + .associate { it.extension.pkgName to it.extension } + + untrustedExtensionMapFlow.value = extensions + .filterIsInstance() + .associate { it.extension.pkgName to it.extension } + + _isInitialized.value = true + } + + suspend fun findAvailableExtensions() { + val extensions: List = try { + api.findExtensions() + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + withUIContext { context.toast(MR.strings.extension_api_error) } + return + } + + availableExtensionMapFlow.value = extensions.associateBy { + it.pkgName + ":${it.signatureHash}" + } + updatedInstalledExtensionsStatuses(extensions) + setupAvailableExtensionsSourcesDataMap(extensions) + } + + private fun updatedInstalledExtensionsStatuses(availableExtensions: List) { + val installedExtensionsMap = installedExtensionMapFlow.value.toMutableMap() + var changed = false + for ((pkgName, extension) in installedExtensionsMap) { + val availableExt = availableExtensions.find { + it.signatureHash == extension.signatureHash && it.pkgName == pkgName + } + + if (availableExt == null && (!extension.isObsolete || extension.hasUpdate)) { + installedExtensionsMap[pkgName] = extension.copy( + isObsolete = true, + hasUpdate = false, + ) + changed = true + } else if (availableExt != null) { + val hasUpdate = extension.updateExists(availableExt) + if (hasUpdate != extension.hasUpdate || + availableExt.repoUrl != extension.repoUrl || + extension.isObsolete + ) { + installedExtensionsMap[pkgName] = extension.copy( + hasUpdate = hasUpdate, + repoUrl = availableExt.repoUrl, + isObsolete = false, + ) + changed = true + } + } + } + if (changed) { + installedExtensionMapFlow.value = installedExtensionsMap + } + updatePendingUpdatesCount() + } + + fun installExtension(extension: AnimeExtension.Available): Flow { + return installer.downloadAndInstall(api.getApkUrl(extension), extension) + } + + fun updateExtension(extension: AnimeExtension.Installed): Flow { + val availableExt = availableExtensionMapFlow.value[ + extension.pkgName + ":${extension.signatureHash}", + ] ?: return emptyFlow() + return installExtension(availableExt) + } + + fun cancelInstallUpdateExtension(extension: AnimeExtension) { + installer.cancelInstall(extension.pkgName + ":${extension.signatureHash}") + } + + fun setInstalling(downloadId: Long) { + installer.updateInstallStep(downloadId, InstallStep.Installing) + } + + fun updateInstallStep(downloadId: Long, step: InstallStep) { + installer.updateInstallStep(downloadId, step) + } + + fun uninstallExtension(extension: AnimeExtension) { + installer.uninstallApk(extension.pkgName) + } + + suspend fun trust(extension: AnimeExtension.Untrusted) { + untrustedExtensionMapFlow.value[extension.pkgName] ?: return + + trustExtension.trust(extension.pkgName, extension.versionCode, extension.signatureHash) + + untrustedExtensionMapFlow.value -= extension.pkgName + + AnimeExtensionLoader.loadExtensionFromPkgName(context, extension.pkgName) + .let { it as? AnimeLoadResult.Success } + ?.let { registerNewExtension(it.extension) } + } + + private fun registerNewExtension(extension: AnimeExtension.Installed) { + installedExtensionMapFlow.value += extension + } + + private fun registerUpdatedExtension(extension: AnimeExtension.Installed) { + installedExtensionMapFlow.value += extension + } + + private fun unregisterExtension(pkgName: String) { + installedExtensionMapFlow.value -= pkgName + untrustedExtensionMapFlow.value -= pkgName + } + + private inner class InstallationListener : AnimeExtensionInstallReceiver.Listener { + + override fun onExtensionInstalled(extension: AnimeExtension.Installed) { + registerNewExtension(extension.withUpdateCheck()) + updatePendingUpdatesCount() + } + + override fun onExtensionUpdated(extension: AnimeExtension.Installed) { + registerUpdatedExtension(extension.withUpdateCheck()) + updatePendingUpdatesCount() + } + + override fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) { + installedExtensionMapFlow.value -= extension.pkgName + untrustedExtensionMapFlow.value += extension + updatePendingUpdatesCount() + } + + override fun onPackageUninstalled(pkgName: String) { + AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName) + unregisterExtension(pkgName) + updatePendingUpdatesCount() + } + } + + private fun AnimeExtension.Installed.withUpdateCheck(): AnimeExtension.Installed { + return if (updateExists()) { + copy(hasUpdate = true) + } else { + this + } + } + + private fun AnimeExtension.Installed.updateExists(availableExtension: AnimeExtension.Available? = null): Boolean { + val availableExt = availableExtension + ?: availableExtensionMapFlow.value[pkgName + ":${signatureHash}"] + ?: return false + + return (availableExt.versionCode > versionCode || availableExt.libVersion > libVersion) + } + + private fun updatePendingUpdatesCount() { + val pendingUpdateCount = installedExtensionMapFlow.value.values.count { it.hasUpdate } + preferences.animeExtensionUpdatesCount().set(pendingUpdateCount) + if (pendingUpdateCount == 0) { + AnimeExtensionUpdateNotifier(context).dismiss() + } + } + + private operator fun Map.plus(extension: T) = plus(extension.pkgName to extension) + + private fun StateFlow>.mapExtensions(scope: CoroutineScope): StateFlow> { + return map { it.values.toList() }.stateIn(scope, SharingStarted.Lazily, value.values.toList()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionApi.kt new file mode 100644 index 0000000000..30d0466481 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionApi.kt @@ -0,0 +1,179 @@ +package eu.kanade.tachiyomi.animeextension.api + +import android.content.Context +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animeextension.util.AnimeExtensionLoader +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.network.parseAs +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import logcat.LogPriority +import mihon.domain.animeextensionrepo.interactor.GetAnimeExtensionRepo +import mihon.domain.animeextensionrepo.interactor.UpdateAnimeExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo +import tachiyomi.core.common.preference.Preference +import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.lang.withIOContext +import tachiyomi.core.common.util.system.logcat +import uy.kohesive.injekt.injectLazy +import java.time.Instant +import kotlin.time.Duration.Companion.days + +internal class AnimeExtensionApi { + + private val networkService: NetworkHelper by injectLazy() + private val preferenceStore: PreferenceStore by injectLazy() + private val getAnimeExtensionRepo: GetAnimeExtensionRepo by injectLazy() + private val updateAnimeExtensionRepo: UpdateAnimeExtensionRepo by injectLazy() + private val animeExtensionManager: AnimeExtensionManager by injectLazy() + + private val json: Json by injectLazy() + + private val lastExtCheck: Preference by lazy { + preferenceStore.getLong(Preference.appStateKey("last_anime_ext_check"), 0) + } + + suspend fun findExtensions(): List { + return withIOContext { + getAnimeExtensionRepo.getAll() + .map { async { getExtensions(it) } } + .awaitAll() + .flatten() + } + } + + private suspend fun getExtensions(extRepo: ExtensionRepo): List { + val repoBaseUrl = extRepo.baseUrl + return try { + val response = networkService.client + .newCall(GET("$repoBaseUrl/index.min.json")) + .awaitSuccess() + + with(json) { + response + .parseAs>() + .toExtensions( + repoBaseUrl, + signature = extRepo.signingKeyFingerprint, + ) + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to get anime extensions from $repoBaseUrl" } + emptyList() + } + } + + suspend fun checkForUpdates( + context: Context, + fromAvailableExtensionList: Boolean = false, + ): List? { + if (!fromAvailableExtensionList && + Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds + ) { + return null + } + + updateAnimeExtensionRepo.awaitAll() + + val extensions = if (fromAvailableExtensionList) { + animeExtensionManager.availableExtensionsFlow.value + } else { + findExtensions().also { lastExtCheck.set(Instant.now().toEpochMilli()) } + } + + val installedExtensions = AnimeExtensionLoader.loadExtensions(context) + .filterIsInstance() + .map { it.extension } + + val extensionsWithUpdate = mutableListOf() + for (installedExt in installedExtensions) { + val pkgName = installedExt.pkgName + val availableExt = extensions.find { it.pkgName == pkgName } ?: continue + val hasUpdatedVer = availableExt.versionCode > installedExt.versionCode + val hasUpdatedLib = availableExt.libVersion > installedExt.libVersion + val hasUpdate = hasUpdatedVer || hasUpdatedLib + if (hasUpdate) { + extensionsWithUpdate.add(installedExt) + } + } + + if (extensionsWithUpdate.isNotEmpty()) { + AnimeExtensionUpdateNotifier(context).promptUpdates(extensionsWithUpdate.map { it.name }) + } + + return extensionsWithUpdate + } + + private fun List.toExtensions( + repoUrl: String, + signature: String, + ): List { + return this + .filter { + val libVersion = it.extractLibVersion() + libVersion >= AnimeExtensionLoader.LIB_VERSION_MIN && libVersion <= AnimeExtensionLoader.LIB_VERSION_MAX + } + .map { + AnimeExtension.Available( + name = it.name.substringAfter("Tachiyomi: "), + pkgName = it.pkg, + versionName = it.version, + versionCode = it.code, + libVersion = it.extractLibVersion(), + lang = it.lang, + isNsfw = it.nsfw == 1, + isTorrent = it.torrent == 1, + sources = it.sources?.map(animeExtensionSourceMapper).orEmpty(), + apkName = it.apk, + iconUrl = "$repoUrl/icon/${it.pkg}.png", + repoUrl = repoUrl, + signatureHash = signature, + ) + } + } + + fun getApkUrl(extension: AnimeExtension.Available): String { + return "${extension.repoUrl}/apk/${extension.apkName}" + } + + private fun AnimeExtensionJsonObject.extractLibVersion(): Double { + return version.substringBeforeLast('.').toDouble() + } +} + +@Serializable +private data class AnimeExtensionJsonObject( + val name: String, + val pkg: String, + val apk: String, + val lang: String, + val code: Long, + val version: String, + val nsfw: Int, + val torrent: Int = 0, + val sources: List?, +) + +@Serializable +private data class AnimeExtensionSourceJsonObject( + val id: Long, + val lang: String, + val name: String, + val baseUrl: String, +) + +private val animeExtensionSourceMapper: (AnimeExtensionSourceJsonObject) -> AnimeExtension.Available.Source = { + AnimeExtension.Available.Source( + id = it.id, + lang = it.lang, + name = it.name, + baseUrl = it.baseUrl, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionUpdateNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionUpdateNotifier.kt new file mode 100644 index 0000000000..605f32bbaa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/api/AnimeExtensionUpdateNotifier.kt @@ -0,0 +1,50 @@ +package eu.kanade.tachiyomi.animeextension.api + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.cancelNotification +import eu.kanade.tachiyomi.util.system.notify +import tachiyomi.core.common.i18n.pluralStringResource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeExtensionUpdateNotifier( + private val context: Context, + private val securityPreferences: SecurityPreferences = Injekt.get(), +) { + fun promptUpdates(names: List) { + context.notify( + Notifications.ID_UPDATES_TO_ANIME_EXTS, + Notifications.CHANNEL_ANIME_EXTENSIONS_UPDATE, + ) { + setContentTitle( + context.pluralStringResource( + MR.plurals.update_check_notification_ext_updates, + names.size, + names.size, + ), + ) + if (!securityPreferences.hideNotificationContent().get()) { + val extNames = names.joinToString(", ") + setContentText(extNames) + setStyle(NotificationCompat.BigTextStyle().bigText(extNames)) + } + setSmallIcon(R.drawable.ic_extension_24dp) + setColor(ContextCompat.getColor(context, R.color.ic_launcher)) + setLargeIcon(BitmapFactory.decodeResource(context.resources, R.drawable.chimahon)) + setContentIntent(NotificationReceiver.openExtensionsPendingActivity(context)) + setAutoCancel(true) + } + } + + fun dismiss() { + context.cancelNotification(Notifications.ID_UPDATES_TO_ANIME_EXTS) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt new file mode 100644 index 0000000000..20767dc55c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeExtension.kt @@ -0,0 +1,81 @@ +package eu.kanade.tachiyomi.animeextension.model + +import android.graphics.drawable.Drawable +import eu.kanade.tachiyomi.animesource.AnimeSource +import tachiyomi.domain.animesource.model.StubAnimeSource + +sealed class AnimeExtension { + + abstract val name: String + abstract val pkgName: String + abstract val versionName: String + abstract val versionCode: Long + abstract val libVersion: Double + abstract val lang: String? + abstract val isNsfw: Boolean + abstract val isTorrent: Boolean + abstract val signatureHash: String + + data class Installed( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Long, + override val libVersion: Double, + override val lang: String, + override val isNsfw: Boolean, + override val isTorrent: Boolean, + override val signatureHash: String, + val pkgFactory: String?, + val sources: List, + val icon: Drawable?, + val hasUpdate: Boolean = false, + val isObsolete: Boolean = false, + val isShared: Boolean, + val repoUrl: String? = null, + ) : AnimeExtension() + + data class Available( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Long, + override val libVersion: Double, + override val lang: String, + override val isNsfw: Boolean, + override val isTorrent: Boolean, + override val signatureHash: String, + val sources: List, + val apkName: String, + val iconUrl: String, + val repoUrl: String, + ) : AnimeExtension() { + + data class Source( + val id: Long, + val lang: String, + val name: String, + val baseUrl: String, + ) { + fun toStubAnimeSource(): StubAnimeSource { + return StubAnimeSource( + id = this.id, + lang = this.lang, + name = this.name, + ) + } + } + } + + data class Untrusted( + override val name: String, + override val pkgName: String, + override val versionName: String, + override val versionCode: Long, + override val libVersion: Double, + override val signatureHash: String, + override val lang: String? = null, + override val isNsfw: Boolean = false, + override val isTorrent: Boolean = false, + ) : AnimeExtension() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt new file mode 100644 index 0000000000..d2bcf47d41 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/model/AnimeLoadResult.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.animeextension.model + +sealed interface AnimeLoadResult { + data class Success(val extension: AnimeExtension.Installed) : AnimeLoadResult + data class Untrusted(val extension: AnimeExtension.Untrusted) : AnimeLoadResult + data object Error : AnimeLoadResult +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt new file mode 100644 index 0000000000..21ba0cc44d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstallReceiver.kt @@ -0,0 +1,120 @@ +package eu.kanade.tachiyomi.animeextension.util + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import androidx.core.net.toUri +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat + +internal class AnimeExtensionInstallReceiver(private val listener: Listener) : BroadcastReceiver() { + + val scope = CoroutineScope(SupervisorJob()) + + fun register(context: Context) { + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + } + + private val filter = IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addAction(ACTION_ANIME_EXTENSION_ADDED) + addAction(ACTION_ANIME_EXTENSION_REPLACED) + addAction(ACTION_ANIME_EXTENSION_REMOVED) + addDataScheme("package") + } + + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null) return + + when (intent.action) { + Intent.ACTION_PACKAGE_ADDED, ACTION_ANIME_EXTENSION_ADDED -> { + if (isReplacing(intent)) return + + scope.launch { + when (val result = getExtensionFromIntent(context, intent)) { + is AnimeLoadResult.Success -> listener.onExtensionInstalled(result.extension) + is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + else -> {} + } + } + } + Intent.ACTION_PACKAGE_REPLACED, ACTION_ANIME_EXTENSION_REPLACED -> { + scope.launch { + when (val result = getExtensionFromIntent(context, intent)) { + is AnimeLoadResult.Success -> listener.onExtensionUpdated(result.extension) + is AnimeLoadResult.Untrusted -> listener.onExtensionUntrusted(result.extension) + else -> {} + } + } + } + Intent.ACTION_PACKAGE_REMOVED, ACTION_ANIME_EXTENSION_REMOVED -> { + if (isReplacing(intent)) return + + val pkgName = getPackageNameFromIntent(intent) + if (pkgName != null) { + listener.onPackageUninstalled(pkgName) + } + } + } + } + + private fun isReplacing(intent: Intent): Boolean { + return intent.getBooleanExtra(Intent.EXTRA_REPLACING, false) + } + + private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): AnimeLoadResult { + val pkgName = getPackageNameFromIntent(intent) + if (pkgName == null) { + logcat(LogPriority.WARN) { "Anime extension package name not found" } + return AnimeLoadResult.Error + } + return AnimeExtensionLoader.loadExtensionFromPkgName(context, pkgName) + } + + private fun getPackageNameFromIntent(intent: Intent?): String? { + return intent?.data?.encodedSchemeSpecificPart ?: return null + } + + interface Listener { + fun onExtensionInstalled(extension: AnimeExtension.Installed) + fun onExtensionUpdated(extension: AnimeExtension.Installed) + fun onExtensionUntrusted(extension: AnimeExtension.Untrusted) + fun onPackageUninstalled(pkgName: String) + } + + companion object { + private const val ACTION_ANIME_EXTENSION_ADDED = "${BuildConfig.APPLICATION_ID}.ACTION_ANIME_EXTENSION_ADDED" + private const val ACTION_ANIME_EXTENSION_REPLACED = "${BuildConfig.APPLICATION_ID}.ACTION_ANIME_EXTENSION_REPLACED" + private const val ACTION_ANIME_EXTENSION_REMOVED = "${BuildConfig.APPLICATION_ID}.ACTION_ANIME_EXTENSION_REMOVED" + + fun notifyAdded(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_ANIME_EXTENSION_ADDED) + } + + fun notifyReplaced(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_ANIME_EXTENSION_REPLACED) + } + + fun notifyRemoved(context: Context, pkgName: String) { + notify(context, pkgName, ACTION_ANIME_EXTENSION_REMOVED) + } + + private fun notify(context: Context, pkgName: String, action: String) { + Intent(action).apply { + data = "package:$pkgName".toUri() + `package` = context.packageName + context.sendBroadcast(this) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt new file mode 100644 index 0000000000..f676a6de43 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionInstaller.kt @@ -0,0 +1,242 @@ +package eu.kanade.tachiyomi.animeextension.util + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.Uri +import android.os.Environment +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.net.toUri +import eu.kanade.domain.base.BasePreferences +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.extension.installer.Installer +import eu.kanade.tachiyomi.extension.model.InstallStep +import eu.kanade.tachiyomi.extension.util.ExtensionInstallActivity +import eu.kanade.tachiyomi.extension.util.ExtensionInstallService +import eu.kanade.tachiyomi.util.storage.getUriCompat +import eu.kanade.tachiyomi.util.system.isPackageInstalled +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.transformWhile +import logcat.LogPriority +import tachiyomi.core.common.util.lang.withUIContext +import tachiyomi.core.common.util.system.logcat +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import kotlin.time.Duration.Companion.seconds + +internal class AnimeExtensionInstaller(private val context: Context) { + + private val downloadManager = context.getSystemService()!! + + private val downloadReceiver = DownloadCompletionReceiver() + + private val activeDownloads = hashMapOf() + + private val downloadsStateFlows = hashMapOf>() + + private val extensionInstaller = Injekt.get().extensionInstaller() + + fun downloadAndInstall(url: String, extension: AnimeExtension): Flow { + val pkgName = extension.pkgName + ":${extension.signatureHash}" + + val oldDownload = activeDownloads[pkgName] + if (oldDownload != null) { + deleteDownload(pkgName) + } + + downloadReceiver.register() + + val downloadUri = url.toUri() + val request = DownloadManager.Request(downloadUri) + .setTitle(extension.name) + .setMimeType(APK_MIME) + .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + + val id = downloadManager.enqueue(request) + activeDownloads[pkgName] = id + + val downloadStateFlow = MutableStateFlow(InstallStep.Pending) + downloadsStateFlows[id] = downloadStateFlow + + val pollStatusFlow = downloadStatusFlow(id).mapNotNull { downloadStatus -> + when (downloadStatus) { + DownloadManager.STATUS_PENDING -> InstallStep.Pending + DownloadManager.STATUS_RUNNING -> InstallStep.Downloading + else -> null + } + } + + return merge(downloadStateFlow, pollStatusFlow).transformWhile { + emit(it) + !it.isCompleted() + }.onCompletion { + withUIContext { + deleteDownload(pkgName) + } + } + } + + private fun downloadStatusFlow(id: Long): Flow = flow { + val query = DownloadManager.Query().setFilterById(id) + while (true) { + val downloadStatus = downloadManager.query(query).use { cursor -> + if (!cursor.moveToFirst()) return@flow + cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) + } + + emit(downloadStatus) + + if ( + downloadStatus == DownloadManager.STATUS_SUCCESSFUL || + downloadStatus == DownloadManager.STATUS_FAILED + ) { + return@flow + } + + delay(1.seconds) + } + } + .distinctUntilChanged() + + fun installApk(downloadId: Long, uri: Uri) { + when (val installer = extensionInstaller.get()) { + BasePreferences.ExtensionInstaller.LEGACY -> { + val intent = Intent(context, ExtensionInstallActivity::class.java) + .setDataAndType(uri, APK_MIME) + .putExtra(EXTRA_DOWNLOAD_ID, downloadId) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + + context.startActivity(intent) + } + BasePreferences.ExtensionInstaller.PRIVATE -> { + val animeExtensionManager = Injekt.get() + val tempFile = File(context.cacheDir, "anime_temp_$downloadId") + + if (tempFile.exists() && !tempFile.delete()) { + animeExtensionManager.updateInstallStep(downloadId, InstallStep.Error) + return + } + + try { + context.contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + + if (AnimeExtensionLoader.installPrivateExtensionFile(context, tempFile)) { + animeExtensionManager.updateInstallStep(downloadId, InstallStep.Installed) + } else { + animeExtensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to read downloaded anime extension file." } + animeExtensionManager.updateInstallStep(downloadId, InstallStep.Error) + } + + tempFile.delete() + } + else -> { + val intent = ExtensionInstallService.getIntent(context, downloadId, uri, installer) + ContextCompat.startForegroundService(context, intent) + } + } + } + + fun cancelInstall(pkgName: String) { + val downloadId = activeDownloads.remove(pkgName) ?: return + downloadManager.remove(downloadId) + Installer.cancelInstallQueue(context, downloadId) + } + + fun uninstallApk(pkgName: String) { + if (context.isPackageInstalled(pkgName)) { + @Suppress("DEPRECATION") + val intent = Intent(Intent.ACTION_UNINSTALL_PACKAGE, "package:$pkgName".toUri()) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else { + AnimeExtensionLoader.uninstallPrivateExtension(context, pkgName) + AnimeExtensionInstallReceiver.notifyRemoved(context, pkgName) + } + } + + fun updateInstallStep(downloadId: Long, step: InstallStep) { + downloadsStateFlows[downloadId]?.let { it.value = step } + } + + private fun deleteDownload(pkgName: String) { + val downloadId = activeDownloads.remove(pkgName) + if (downloadId != null) { + downloadManager.remove(downloadId) + downloadsStateFlows.remove(downloadId) + } + if (activeDownloads.isEmpty()) { + downloadReceiver.unregister() + } + } + + private inner class DownloadCompletionReceiver : BroadcastReceiver() { + + private var isRegistered = false + + fun register() { + if (isRegistered) return + isRegistered = true + + val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ContextCompat.registerReceiver(context, this, filter, ContextCompat.RECEIVER_EXPORTED) + } + + fun unregister() { + if (!isRegistered) return + isRegistered = false + + context.unregisterReceiver(this) + } + + override fun onReceive(context: Context, intent: Intent?) { + val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0) ?: return + + if (id !in activeDownloads.values) return + + val uri = downloadManager.getUriForDownloadedFile(id) + + if (uri == null) { + logcat(LogPriority.ERROR) { "Couldn't locate downloaded anime extension APK" } + updateInstallStep(id, InstallStep.Error) + return + } + + val query = DownloadManager.Query().setFilterById(id) + downloadManager.query(query).use { cursor -> + if (cursor.moveToFirst()) { + val localUri = cursor.getString( + cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_LOCAL_URI), + ).removePrefix(FILE_SCHEME) + + installApk(id, File(localUri).getUriCompat(context)) + } + } + } + } + + companion object { + const val APK_MIME = "application/vnd.android.package-archive" + const val EXTRA_DOWNLOAD_ID = "AnimeExtensionInstaller.extra.DOWNLOAD_ID" + const val FILE_SCHEME = "file://" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt new file mode 100644 index 0000000000..9bb099354c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animeextension/util/AnimeExtensionLoader.kt @@ -0,0 +1,349 @@ +package eu.kanade.tachiyomi.animeextension.util + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import eu.kanade.domain.extension.interactor.TrustExtension +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.tachiyomi.animeextension.model.AnimeExtension +import eu.kanade.tachiyomi.animeextension.model.AnimeLoadResult +import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource +import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.animesource.AnimeSourceFactory +import eu.kanade.tachiyomi.util.lang.Hash +import eu.kanade.tachiyomi.util.storage.copyAndSetReadOnlyTo +import eu.kanade.tachiyomi.util.system.ChildFirstPathClassLoader +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import logcat.LogPriority +import mihon.domain.animeextensionrepo.interactor.GetAnimeExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo +import tachiyomi.core.common.util.system.logcat +import uy.kohesive.injekt.injectLazy +import java.io.File + +internal object AnimeExtensionLoader { + + private val preferences: SourcePreferences by injectLazy() + private val trustExtension: TrustExtension by injectLazy() + private val getAnimeExtensionRepo: GetAnimeExtensionRepo by injectLazy() + + private val loadNsfwSource by lazy { + preferences.showNsfwSource().get() + } + + private const val EXTENSION_FEATURE = "tachiyomi.animeextension" + private const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class" + private const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory" + private const val METADATA_NSFW = "tachiyomi.animeextension.nsfw" + private const val METADATA_TORRENT = "tachiyomi.animeextension.torrent" + const val LIB_VERSION_MIN = 14.0 + const val LIB_VERSION_MAX = 15.0 + + @Suppress("DEPRECATION") + private val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or + PackageManager.GET_META_DATA or + PackageManager.GET_SIGNATURES or + (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) PackageManager.GET_SIGNING_CERTIFICATES else 0) + + private const val PRIVATE_EXTENSION_EXTENSION = "ext" + + private fun getPrivateExtensionDir(context: Context) = File(context.filesDir, "anime_exts") + + fun installPrivateExtensionFile(context: Context, file: File): Boolean { + val extension = context.packageManager.getPackageArchiveInfo(file.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } ?: return false + val currentExtension = getExtensionPackageInfoFromPkgName(context, extension.packageName) + + if (currentExtension != null) { + if (PackageInfoCompat.getLongVersionCode(extension) < + PackageInfoCompat.getLongVersionCode(currentExtension) + ) { + logcat(LogPriority.ERROR) { "Installed anime extension version is higher. Downgrading is not allowed." } + return false + } + + val extensionSignatures = getSignatures(extension) + if (extensionSignatures.isNullOrEmpty()) { + logcat(LogPriority.ERROR) { "Anime extension to be installed is not signed." } + return false + } + + if (!extensionSignatures.containsAll(getSignatures(currentExtension)!!)) { + logcat(LogPriority.ERROR) { "Installed anime extension signature is not matched." } + return false + } + } + + val target = File(getPrivateExtensionDir(context), "${extension.packageName}.$PRIVATE_EXTENSION_EXTENSION") + return try { + target.delete() + file.copyAndSetReadOnlyTo(target, overwrite = true) + if (currentExtension != null) { + AnimeExtensionInstallReceiver.notifyReplaced(context, extension.packageName) + } else { + AnimeExtensionInstallReceiver.notifyAdded(context, extension.packageName) + } + true + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to copy anime extension file." } + target.delete() + false + } + } + + fun uninstallPrivateExtension(context: Context, pkgName: String) { + File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION").delete() + } + + fun loadExtensions(context: Context): List { + val pkgManager = context.packageManager + + val installedPkgs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + pkgManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(PACKAGE_FLAGS.toLong())) + } else { + pkgManager.getInstalledPackages(PACKAGE_FLAGS) + } + + val sharedExtPkgs = installedPkgs + .asSequence() + .filter { isPackageAnExtension(it) } + .map { ExtensionInfo(packageInfo = it, isShared = true) } + + val privateExtPkgs = getPrivateExtensionDir(context) + .listFiles() + ?.filter { it.isFile && it.extension == PRIVATE_EXTENSION_EXTENSION } + ?.mapNotNull { + if (it.canWrite()) { + it.setReadOnly() + } + + val path = it.absolutePath + pkgManager.getPackageArchiveInfo(path, PACKAGE_FLAGS) + ?.apply { applicationInfo!!.fixBasePaths(path) } + } + ?.filter { isPackageAnExtension(it) } + ?.map { ExtensionInfo(packageInfo = it, isShared = false) } + .orEmpty() + + val extPkgs = (sharedExtPkgs + privateExtPkgs) + .distinctBy { it.packageInfo.packageName } + .mapNotNull { sharedPkg -> + val privatePkg = privateExtPkgs + .singleOrNull { it.packageInfo.packageName == sharedPkg.packageInfo.packageName } + selectExtensionPackage(sharedPkg, privatePkg) + } + .toList() + + if (extPkgs.isEmpty()) return emptyList() + + return runBlocking { + val extRepos = getAnimeExtensionRepo.getAll() + val deferred = extPkgs.map { + async { loadExtension(context, it, extRepos) } + } + deferred.awaitAll() + } + } + + suspend fun loadExtensionFromPkgName(context: Context, pkgName: String): AnimeLoadResult { + val extensionPackage = getExtensionInfoFromPkgName(context, pkgName) + if (extensionPackage == null) { + logcat(LogPriority.ERROR) { "Anime extension package is not found ($pkgName)" } + return AnimeLoadResult.Error + } + return loadExtension(context, extensionPackage) + } + + fun getExtensionPackageInfoFromPkgName(context: Context, pkgName: String): PackageInfo? { + return getExtensionInfoFromPkgName(context, pkgName)?.packageInfo + } + + private fun getExtensionInfoFromPkgName(context: Context, pkgName: String): ExtensionInfo? { + val privateExtensionFile = File(getPrivateExtensionDir(context), "$pkgName.$PRIVATE_EXTENSION_EXTENSION") + val privatePkg = if (privateExtensionFile.isFile) { + context.packageManager.getPackageArchiveInfo(privateExtensionFile.absolutePath, PACKAGE_FLAGS) + ?.takeIf { isPackageAnExtension(it) } + ?.let { + it.applicationInfo!!.fixBasePaths(privateExtensionFile.absolutePath) + ExtensionInfo(packageInfo = it, isShared = false) + } + } else { + null + } + + val sharedPkg = try { + context.packageManager.getPackageInfo(pkgName, PACKAGE_FLAGS) + .takeIf { isPackageAnExtension(it) } + ?.let { ExtensionInfo(packageInfo = it, isShared = true) } + } catch (error: PackageManager.NameNotFoundException) { + null + } + + return selectExtensionPackage(sharedPkg, privatePkg) + } + + private suspend fun loadExtension( + context: Context, + extensionInfo: ExtensionInfo, + extRepos: List? = null, + ): AnimeLoadResult { + val repos = extRepos ?: getAnimeExtensionRepo.getAll() + val pkgManager = context.packageManager + val pkgInfo = extensionInfo.packageInfo + val appInfo = pkgInfo.applicationInfo!! + val pkgName = pkgInfo.packageName + + val extName = pkgManager.getApplicationLabel(appInfo).toString().substringAfter("Tachiyomi: ") + val versionName = pkgInfo.versionName + val versionCode = PackageInfoCompat.getLongVersionCode(pkgInfo) + + if (versionName.isNullOrEmpty()) { + logcat(LogPriority.WARN) { "Missing versionName for anime extension $extName" } + return AnimeLoadResult.Error + } + + val libVersion = versionName.substringBeforeLast('.').toDoubleOrNull() + if (libVersion == null || libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) { + logcat(LogPriority.WARN) { + "Anime lib version is $libVersion, while only versions " + + "$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed" + } + return AnimeLoadResult.Error + } + + val signatures = getSignatures(pkgInfo) + if (signatures.isNullOrEmpty()) { + logcat(LogPriority.WARN) { "Package $pkgName isn't signed" } + return AnimeLoadResult.Error + } else if (!trustExtension.isTrusted(pkgInfo, signatures)) { + val extension = AnimeExtension.Untrusted( + extName, + pkgName, + versionName, + versionCode, + libVersion, + signatures.last(), + ) + logcat(LogPriority.WARN) { "Anime extension $pkgName isn't trusted" } + return AnimeLoadResult.Untrusted(extension) + } + + val isNsfw = appInfo.metaData.getInt(METADATA_NSFW) == 1 + if (!loadNsfwSource && isNsfw) { + logcat(LogPriority.WARN) { "NSFW anime extension $pkgName not allowed" } + return AnimeLoadResult.Error + } + val isTorrent = appInfo.metaData.getInt(METADATA_TORRENT) == 1 + + val classLoader = try { + ChildFirstPathClassLoader(appInfo.sourceDir, null, context.classLoader) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Anime extension load error: $extName ($pkgName)" } + return AnimeLoadResult.Error + } + + val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!! + .split(";") + .map { + val sourceClass = it.trim() + if (sourceClass.startsWith(".")) { + pkgInfo.packageName + sourceClass + } else { + sourceClass + } + } + .flatMap { + try { + when (val obj = Class.forName(it, false, classLoader).getDeclaredConstructor().newInstance()) { + is AnimeSource -> listOf(obj) + is AnimeSourceFactory -> obj.createSources() + else -> throw Exception("Unknown anime source class type: ${obj.javaClass}") + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Anime extension load error: $extName ($it)" } + return AnimeLoadResult.Error + } + } + + val langs = sources.filterIsInstance() + .map { it.lang } + .toSet() + val lang = when (langs.size) { + 0 -> "" + 1 -> langs.first() + else -> "all" + } + + val extension = AnimeExtension.Installed( + name = extName, + pkgName = pkgName, + versionName = versionName, + versionCode = versionCode, + libVersion = libVersion, + lang = lang, + isNsfw = isNsfw, + isTorrent = isTorrent, + sources = sources, + pkgFactory = appInfo.metaData.getString(METADATA_SOURCE_FACTORY), + icon = appInfo.loadIcon(pkgManager), + isShared = extensionInfo.isShared, + signatureHash = signatures.last(), + ) + return AnimeLoadResult.Success(extension) + } + + private fun selectExtensionPackage(shared: ExtensionInfo?, private: ExtensionInfo?): ExtensionInfo? { + when { + private == null && shared != null -> return shared + shared == null && private != null -> return private + shared == null && private == null -> return null + } + + return if (PackageInfoCompat.getLongVersionCode(shared!!.packageInfo) >= + PackageInfoCompat.getLongVersionCode(private!!.packageInfo) + ) { + shared + } else { + private + } + } + + private fun isPackageAnExtension(pkgInfo: PackageInfo): Boolean { + return pkgInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE } + } + + private fun getSignatures(pkgInfo: PackageInfo): List? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val signingInfo = pkgInfo.signingInfo!! + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners + } else { + signingInfo.signingCertificateHistory + } + } else { + @Suppress("DEPRECATION") + pkgInfo.signatures + } + ?.map { Hash.sha256(it.toByteArray()) } + ?.toList() + } + + private fun ApplicationInfo.fixBasePaths(apkPath: String) { + if (sourceDir == null) { + sourceDir = apkPath + } + if (publicSourceDir == null) { + publicSourceDir = apkPath + } + } + + private data class ExtensionInfo( + val packageInfo: PackageInfo, + val isShared: Boolean, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/animesource/AndroidAnimeSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/animesource/AndroidAnimeSourceManager.kt new file mode 100644 index 0000000000..ac47200662 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/animesource/AndroidAnimeSourceManager.kt @@ -0,0 +1,103 @@ +package eu.kanade.tachiyomi.animesource + +import android.content.Context +import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import tachiyomi.domain.animesource.model.StubAnimeSource +import tachiyomi.domain.animesource.repository.StubAnimeSourceRepository +import tachiyomi.domain.animesource.service.AnimeSourceManager + +class AndroidAnimeSourceManager( + private val context: Context, + private val animeExtensionManager: AnimeExtensionManager, + private val animeSourceRepository: StubAnimeSourceRepository, +) : AnimeSourceManager { + + private val _isInitialized = MutableStateFlow(false) + override val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val scope = CoroutineScope(Job() + Dispatchers.IO) + + private val sourcesMapFlow = MutableStateFlow(HashMap()) + + private val stubSourcesMap = HashMap() + + override val catalogueSources: Flow> = sourcesMapFlow.map { + it.values.filterIsInstance() + } + + init { + scope.launch { + animeExtensionManager.installedExtensionsFlow + .collectLatest { extensions -> + val mutableMap = HashMap() + extensions.forEach { extension -> + extension.sources.forEach { source -> + mutableMap[source.id] = source + registerStubSource(StubAnimeSource.from(source)) + } + } + sourcesMapFlow.value = mutableMap + _isInitialized.value = true + } + } + + scope.launch { + animeSourceRepository.subscribeAll() + .collectLatest { sources -> + sources.forEach { + stubSourcesMap[it.id] = it + } + } + } + } + + override fun get(sourceKey: Long): eu.kanade.tachiyomi.animesource.AnimeSource? { + return sourcesMapFlow.value[sourceKey] + } + + override fun getOrStub(sourceKey: Long): eu.kanade.tachiyomi.animesource.AnimeSource { + return sourcesMapFlow.value[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) { + runBlocking { createStubSource(sourceKey) } + } + } + + override fun getOnlineSources() = sourcesMapFlow.value.values.filterIsInstance() + + override fun getCatalogueSources() = sourcesMapFlow.value.values.filterIsInstance() + + override fun getStubSources(): List { + val onlineSourceIds = getOnlineSources().map { it.id } + return stubSourcesMap.values.filterNot { it.id in onlineSourceIds } + } + + private fun registerStubSource(source: StubAnimeSource) { + scope.launch { + val dbSource = animeSourceRepository.getStubSource(source.id) + if (dbSource == source) return@launch + animeSourceRepository.upsertStubSource(source.id, source.lang, source.name) + } + } + + private suspend fun createStubSource(id: Long): StubAnimeSource { + animeSourceRepository.getStubSource(id)?.let { + return it + } + animeExtensionManager.getSourceData(id)?.let { + registerStubSource(it) + return it + } + return StubAnimeSource(id = id, lang = "", name = "") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadCache.kt new file mode 100644 index 0000000000..756de67d2b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadCache.kt @@ -0,0 +1,307 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadProvider.Companion.TMP_DIR_SUFFIX +import eu.kanade.tachiyomi.data.download.UniFileAsStringSerializer +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf +import logcat.LogPriority +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.lang.launchNonCancellable +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.animesource.service.AnimeSourceManager +import tachiyomi.domain.episode.model.Episode +import tachiyomi.domain.storage.service.StorageManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import kotlin.time.Duration.Companion.hours + +class AnimeDownloadCache( + private val context: Context, + private val provider: AnimeDownloadProvider = Injekt.get(), + private val animeSourceManager: AnimeSourceManager = Injekt.get(), + private val storageManager: StorageManager = Injekt.get(), +) { + + private val scope = CoroutineScope(Dispatchers.IO) + + private val _changes: Channel = Channel(Channel.UNLIMITED) + val changes = _changes.receiveAsFlow() + .onStart { emit(Unit) } + .shareIn(scope, SharingStarted.Lazily, 1) + + private val renewInterval = 1.hours.inWholeMilliseconds + + private var lastRenew = 0L + private var renewalJob: Job? = null + + private val _isInitializing = MutableStateFlow(false) + val isInitializing = _isInitializing + .debounce(1000L) + .stateIn(scope, SharingStarted.WhileSubscribed(), false) + + private val diskCacheFile: File + get() = File(context.cacheDir, "anime_dl_index_cache_v1") + + private val rootDirMutex = Mutex() + private var rootDir = RootDirectory(storageManager.getAnimeDownloadsDirectory()) + + init { + scope.launch { + rootDirMutex.withLock { + try { + if (diskCacheFile.exists()) { + val diskCache = diskCacheFile.inputStream().use { + ProtoBuf.decodeFromByteArray(it.readBytes()) + } + rootDir = diskCache + lastRenew = System.currentTimeMillis() + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to initialize anime download cache from disk" } + diskCacheFile.delete() + } + } + } + + storageManager.changes + .onEach { invalidateCache() } + .launchIn(scope) + } + + fun isEpisodeDownloaded( + episodeName: String, + episodeScanlator: String?, + animeTitle: String, + sourceId: Long, + skipCache: Boolean = false, + ): Boolean { + if (skipCache) { + val source = animeSourceManager.getOrStub(sourceId) + return provider.findEpisodeDir(episodeName, episodeScanlator, animeTitle, source) != null + } + + renewCache() + + val sourceDir = rootDir.sourceDirs[sourceId] + if (sourceDir != null) { + val animeDir = sourceDir.animeDirs[provider.getAnimeDirName(animeTitle)] + if (animeDir != null) { + val episodeDirName = provider.getEpisodeDirName(episodeName, episodeScanlator) + return episodeDirName in animeDir.episodeDirs + } + } + return false + } + + fun getDownloadCount(anime: Anime): Int { + renewCache() + + val sourceDir = rootDir.sourceDirs[anime.source] + if (sourceDir != null) { + val animeDir = sourceDir.animeDirs[provider.getAnimeDirName(anime.title)] + if (animeDir != null) { + return animeDir.episodeDirs.size + } + } + return 0 + } + + suspend fun addEpisode(episodeDirName: String, animeUniFile: UniFile, anime: Anime) { + rootDirMutex.withLock { + var sourceDir = rootDir.sourceDirs[anime.source] + if (sourceDir == null) { + val source = animeSourceManager.get(anime.source) ?: return + val sourceUniFile = provider.findSourceDir(source) ?: return + sourceDir = SourceDirectory(sourceUniFile) + rootDir.sourceDirs += anime.source to sourceDir + } + + val animeDirName = provider.getAnimeDirName(anime.title) + var animeDir = sourceDir.animeDirs[animeDirName] + if (animeDir == null) { + animeDir = AnimeDirectory(animeUniFile) + sourceDir.animeDirs += animeDirName to animeDir + } + + animeDir.episodeDirs += episodeDirName + } + + notifyChanges() + } + + suspend fun removeEpisode(episode: Episode, anime: Anime) { + rootDirMutex.withLock { + val sourceDir = rootDir.sourceDirs[anime.source] ?: return + val animeDir = sourceDir.animeDirs[provider.getAnimeDirName(anime.title)] ?: return + val episodeDirName = provider.getEpisodeDirName(episode.name, episode.scanlator) + animeDir.episodeDirs -= episodeDirName + } + + notifyChanges() + } + + suspend fun removeEpisodes(episodes: List, anime: Anime) { + rootDirMutex.withLock { + val sourceDir = rootDir.sourceDirs[anime.source] ?: return + val animeDir = sourceDir.animeDirs[provider.getAnimeDirName(anime.title)] ?: return + episodes.forEach { episode -> + val episodeDirName = provider.getEpisodeDirName(episode.name, episode.scanlator) + animeDir.episodeDirs -= episodeDirName + } + } + + notifyChanges() + } + + suspend fun removeAnime(anime: Anime) { + rootDirMutex.withLock { + val sourceDir = rootDir.sourceDirs[anime.source] ?: return + val animeDirName = provider.getAnimeDirName(anime.title) + if (sourceDir.animeDirs.containsKey(animeDirName)) { + sourceDir.animeDirs -= animeDirName + } + } + + notifyChanges() + } + + fun invalidateCache() { + lastRenew = 0L + renewalJob?.cancel() + diskCacheFile.delete() + renewCache() + } + + private fun renewCache() { + if (lastRenew + renewInterval >= System.currentTimeMillis() || renewalJob?.isActive == true) { + return + } + + renewalJob = scope.launchIO { + if (lastRenew == 0L) { + _isInitializing.emit(true) + } + + val sources = animeSourceManager.getOnlineSources() + animeSourceManager.getStubSources() + val sourceMap = sources.associate { provider.getSourceDirName(it).lowercase() to it.id } + + val updatedRootDir = RootDirectory(storageManager.getAnimeDownloadsDirectory()) + + updatedRootDir.sourceDirs = updatedRootDir.dir?.listFiles().orEmpty() + .filter { it.isDirectory && !it.name.isNullOrBlank() } + .mapNotNull { dir -> + val sourceId = sourceMap[dir.name!!.lowercase()] + sourceId?.let { it to SourceDirectory(dir) } + } + .toMap() + + updatedRootDir.sourceDirs.values.map { sourceDir -> + async { + sourceDir.animeDirs = sourceDir.dir?.listFiles().orEmpty() + .filter { it.isDirectory && !it.name.isNullOrBlank() } + .associate { it.name!! to AnimeDirectory(it) } + + sourceDir.animeDirs.values.forEach { animeDir -> + val episodeDirs = animeDir.dir?.listFiles().orEmpty() + .mapNotNull { + when { + it.name?.endsWith(TMP_DIR_SUFFIX) == true -> null + it.isDirectory -> it.name + else -> null + } + } + .toMutableSet() + + animeDir.episodeDirs = episodeDirs + } + } + }.awaitAll() + + rootDirMutex.withLock { + rootDir = updatedRootDir + } + + _isInitializing.emit(false) + }.also { + it.invokeOnCompletion(onCancelling = true) { exception -> + if (exception != null && exception !is CancellationException) { + logcat(LogPriority.ERROR, exception) { "AnimeDownloadCache: failed to create cache" } + } + lastRenew = System.currentTimeMillis() + notifyChanges() + } + } + + } + + private fun notifyChanges() { + scope.launchNonCancellable { + _changes.send(Unit) + } + updateDiskCache() + } + + private var updateDiskCacheJob: Job? = null + private fun updateDiskCache() { + updateDiskCacheJob?.cancel() + updateDiskCacheJob = scope.launchIO { + delay(1000) + ensureActive() + val bytes = ProtoBuf.encodeToByteArray(rootDir) + ensureActive() + try { + diskCacheFile.writeBytes(bytes) + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) { "Failed to write anime download disk cache" } + } + } + } +} + +@Serializable +private class RootDirectory( + @Serializable(with = UniFileAsStringSerializer::class) + val dir: UniFile?, + var sourceDirs: Map = mapOf(), +) + +@Serializable +private class SourceDirectory( + @Serializable(with = UniFileAsStringSerializer::class) + val dir: UniFile?, + var animeDirs: Map = mapOf(), +) + +@Serializable +private class AnimeDirectory( + @Serializable(with = UniFileAsStringSerializer::class) + val dir: UniFile?, + var episodeDirs: MutableSet = mutableSetOf(), +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadJob.kt new file mode 100644 index 0000000000..ac3c2e32f1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadJob.kt @@ -0,0 +1,138 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.lifecycle.asFlow +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.NetworkState +import eu.kanade.tachiyomi.util.system.activeNetworkState +import eu.kanade.tachiyomi.util.system.networkStateFlow +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.setForegroundSafely +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combineTransform +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.download.service.DownloadPreferences +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeDownloadJob( + private val context: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(context, workerParams) { + + private val animeDownloadManager: AnimeDownloadManager by lazy { Injekt.get() } + private val downloadPreferences: DownloadPreferences by lazy { Injekt.get() } + + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = applicationContext.notificationBuilder(Notifications.CHANNEL_ANIME_DOWNLOADER_PROGRESS) { + setContentTitle(applicationContext.getString(R.string.download_notifier_downloader_title)) + setSmallIcon(android.R.drawable.stat_sys_download) + setColor(ContextCompat.getColor(applicationContext, R.color.ic_launcher)) + }.build() + return ForegroundInfo( + Notifications.ID_ANIME_DOWNLOAD_PROGRESS, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + }, + ) + } + + override suspend fun doWork(): Result { + var networkCheck = checkNetworkState( + applicationContext.activeNetworkState(), + downloadPreferences.downloadOnlyOverWifi().get(), + ) + var active = networkCheck && animeDownloadManager.downloaderStart() + + if (!active) { + logcat(LogPriority.WARN) { "AnimeDownloadJob: not active (networkCheck=$networkCheck)" } + return Result.failure() + } + logcat(LogPriority.INFO) { "AnimeDownloadJob: started successfully" } + + setForegroundSafely() + + coroutineScope { + combineTransform( + applicationContext.networkStateFlow(), + downloadPreferences.downloadOnlyOverWifi().changes(), + transform = { a, b -> emit(checkNetworkState(a, b)) }, + ) + .onEach { networkCheck = it } + .launchIn(this) + } + + while (active) { + active = !isStopped && animeDownloadManager.isRunning && networkCheck + } + + return Result.success() + } + + private fun checkNetworkState(state: NetworkState, requireWifi: Boolean): Boolean { + return if (state.isOnline) { + val noWifi = requireWifi && !state.isWifi + if (noWifi) { + animeDownloadManager.downloaderStop( + applicationContext.getString(R.string.download_notifier_text_only_wifi), + ) + } + !noWifi + } else { + animeDownloadManager.downloaderStop( + applicationContext.getString(R.string.download_notifier_no_network), + ) + false + } + } + + companion object { + private const val TAG = "AnimeDownloader" + + fun start(context: Context) { + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + } + + fun stop(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(TAG) + } + + fun isRunning(context: Context): Boolean { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(TAG) + .get() + .let { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 } + } + + fun isRunningFlow(context: Context): Flow { + return WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(TAG) + .asFlow() + .map { list -> list.count { it.state == WorkInfo.State.RUNNING } == 1 } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadManager.kt new file mode 100644 index 0000000000..929aaebf3a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadManager.kt @@ -0,0 +1,155 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.animesource.model.SerializableVideo.Companion.toVideoList +import eu.kanade.tachiyomi.animesource.model.Track +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import logcat.LogPriority +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.animesource.service.AnimeSourceManager +import tachiyomi.domain.episode.model.Episode +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeDownloadManager( + private val context: Context, + private val provider: AnimeDownloadProvider = Injekt.get(), + private val cache: AnimeDownloadCache = Injekt.get(), + private val animeSourceManager: AnimeSourceManager = Injekt.get(), +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val downloader = AnimeDownloader(context, provider, cache) + private val pendingDeleter = AnimeDownloadPendingDeleter(context) + + val isRunning: Boolean + get() = downloader.isRunning + + val isDownloaderRunning + get() = AnimeDownloadJob.isRunningFlow(context) + + val queueState + get() = downloader.queueState + + val stateVersion + get() = downloader.stateVersion + + suspend fun downloaderStart() = downloader.start() + fun downloaderStop(reason: String? = null) = downloader.stop(reason) + + fun startDownloads() { + if (downloader.isRunning) return + if (AnimeDownloadJob.isRunning(context)) { + scope.launch { downloader.start() } + } else { + AnimeDownloadJob.start(context) + } + } + + fun pauseDownloads() { + downloader.pause() + AnimeDownloadJob.stop(context) + } + + fun clearQueue() { + downloader.clearQueue() + AnimeDownloadJob.stop(context) + } + + fun cancelEpisodeDownload(download: AnimeDownload) { + downloader.removeFromQueue(listOf(download.episode)) + } + + fun downloadEpisodes(anime: Anime, episodes: List, videos: List, autoStart: Boolean = true) { + downloader.queueEpisodes(anime, episodes, videos, autoStart) + } + + fun isEpisodeDownloaded(episodeName: String, scanlator: String?, animeTitle: String, sourceId: Long): Boolean { + return cache.isEpisodeDownloaded(episodeName, scanlator, animeTitle, sourceId) + } + + fun getDownloadCount(anime: Anime): Int { + return cache.getDownloadCount(anime) + } + + suspend fun deleteEpisodes(episodes: List, anime: Anime, source: AnimeSource) { + val (_, episodeDirs) = provider.findEpisodeDirs(episodes, anime, source) + episodeDirs.forEach { it.delete() } + cache.removeEpisodes(episodes, anime) + downloader.removeFromQueue(episodes) + } + + suspend fun deleteAnime(anime: Anime, source: AnimeSource) { + val animeDir = provider.findAnimeDir(anime.title, source) + animeDir?.delete() + cache.removeAnime(anime) + downloader.removeFromQueue(anime) + } + + fun buildVideoForPlayer(anime: Anime, episode: Episode, source: AnimeSource): Video? { + val episodeDir = provider.findEpisodeDir( + episode.name, + episode.scanlator, + anime.title, + source, + ) ?: return null + + val videoFile = episodeDir.listFiles()?.firstOrNull { file -> + val name = file.name ?: return@firstOrNull false + !file.isDirectory && + name != "metadata.json" && + name != ".nomedia" && + !name.startsWith(".") + } ?: return null + + val metadataFile = episodeDir.findFile("metadata.json") + var subtitleTracks = emptyList() + var timestamps = emptyList() + var videoTitle = "" + + if (metadataFile != null) { + try { + val json = metadataFile.openInputStream().use { it.bufferedReader().readText() } + val videos = json.toVideoList() + videos.firstOrNull()?.let { v -> + videoTitle = v.videoTitle + timestamps = v.timestamps + // Remap subtitle tracks to local paths + val subtitleDir = episodeDir.findFile("subtitles") + subtitleTracks = v.subtitleTracks.map { track -> + val localFile = subtitleDir?.findFile("${track.lang}.${subtitleExtensionFromUrl(track.url)}") + Track( + url = localFile?.uri?.toString() ?: track.url, + lang = track.lang, + ) + } + } + } catch (e: Exception) { + logcat(LogPriority.WARN, e) { "Failed to parse metadata for downloaded episode" } + } + } + + return Video( + videoUrl = videoFile.uri.toString(), + videoTitle = videoTitle, + subtitleTracks = subtitleTracks, + timestamps = timestamps, + ) + } + + fun statusFlow(): Flow { + return queueState.map { queue -> + queue.firstOrNull { it.status == AnimeDownload.State.DOWNLOADING } + ?: queue.firstOrNull() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadNotifier.kt new file mode 100644 index 0000000000..4cb2f4d879 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadNotifier.kt @@ -0,0 +1,137 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.core.security.SecurityPreferences +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.data.notification.NotificationHandler +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.lang.chop +import eu.kanade.tachiyomi.util.system.cancelNotification +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notify +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import uy.kohesive.injekt.injectLazy +import java.util.regex.Pattern + +internal class AnimeDownloadNotifier(private val context: Context) { + + private val preferences: SecurityPreferences by injectLazy() + + private val progressNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_ANIME_DOWNLOADER_PROGRESS) { + setColor(ContextCompat.getColor(context, R.color.ic_launcher)) + setAutoCancel(false) + setOnlyAlertOnce(true) + } + } + + private val errorNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_ANIME_DOWNLOADER_ERROR) { + setColor(ContextCompat.getColor(context, R.color.ic_launcher)) + setAutoCancel(false) + } + } + + private var isDownloading = false + + private fun NotificationCompat.Builder.show(id: Int) { + context.notify(id, build()) + } + + fun dismissProgress() { + context.cancelNotification(Notifications.ID_ANIME_DOWNLOAD_PROGRESS) + context.cancelNotification(Notifications.ID_ANIME_DOWNLOAD_PAUSED) + } + + fun dismissPaused() { + context.cancelNotification(Notifications.ID_ANIME_DOWNLOAD_PAUSED) + } + + fun onProgressChange(download: AnimeDownload) { + with(progressNotificationBuilder) { + if (!isDownloading) { + setSmallIcon(android.R.drawable.stat_sys_download) + clearActions() + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + isDownloading = true + addAction( + R.drawable.ic_pause_24dp, + context.stringResource(MR.strings.action_pause), + NotificationReceiver.pauseDownloadsPendingBroadcast(context), + ) + } + + val title = if (preferences.hideNotificationContent().get()) { + context.stringResource(MR.strings.download_notifier_downloader_title) + } else { + val animeTitle = download.anime.title.chop(15) + val quotedTitle = Pattern.quote(animeTitle) + val episode = download.episode.name.replaceFirst( + "$quotedTitle[\\s]*[-]*[\\s]*".toRegex(RegexOption.IGNORE_CASE), + "", + ) + "$animeTitle - $episode".chop(30) + } + setContentTitle(title) + setContentText("${download.progress}%") + setProgress(100, download.progress, false) + setOngoing(true) + + show(Notifications.ID_ANIME_DOWNLOAD_PROGRESS) + } + } + + fun onPaused() { + with(progressNotificationBuilder) { + setContentTitle(context.stringResource(MR.strings.chapter_paused)) + setContentText(context.stringResource(MR.strings.download_notifier_download_paused)) + setSmallIcon(R.drawable.ic_pause_24dp) + setProgress(0, 0, false) + setOngoing(false) + clearActions() + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + addAction( + R.drawable.ic_play_arrow_24dp, + context.stringResource(MR.strings.action_resume), + NotificationReceiver.resumeDownloadsPendingBroadcast(context), + ) + addAction( + R.drawable.ic_close_24dp, + context.stringResource(MR.strings.action_cancel_all), + NotificationReceiver.clearDownloadsPendingBroadcast(context), + ) + + show(Notifications.ID_ANIME_DOWNLOAD_PAUSED) + } + + isDownloading = false + } + + fun onComplete() { + dismissProgress() + isDownloading = false + } + + fun onError(error: String? = null, episodeName: String? = null, animeTitle: String? = null) { + with(errorNotificationBuilder) { + setContentTitle( + animeTitle?.plus(": $episodeName") + ?: context.stringResource(MR.strings.download_notifier_downloader_title), + ) + setContentText(error ?: context.stringResource(MR.strings.download_notifier_unknown_error)) + setSmallIcon(R.drawable.ic_warning_white_24dp) + clearActions() + setContentIntent(NotificationHandler.openDownloadManagerPendingActivity(context)) + setProgress(0, 0, false) + + show(Notifications.ID_ANIME_DOWNLOAD_ERROR) + } + + isDownloading = false + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadPendingDeleter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadPendingDeleter.kt new file mode 100644 index 0000000000..fda28066a4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadPendingDeleter.kt @@ -0,0 +1,75 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import androidx.core.content.edit +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.episode.model.Episode +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeDownloadPendingDeleter( + context: Context, + private val json: Json = Injekt.get(), +) { + + private val preferences = context.getSharedPreferences("anime_episodes_to_delete", Context.MODE_PRIVATE) + + fun enqueueEpisodesToDelete(episodes: List, anime: Anime) { + val existingEntry = preferences.getString(anime.id.toString(), null) + val existingEpisodes = existingEntry?.let { decodeEntry(it)?.episodes } ?: emptyList() + val existingIds = existingEpisodes.map { it.id }.toSet() + + val merged = existingEpisodes + episodes + .filter { it.id !in existingIds } + .map { EpisodeEntry(it.id, it.name, it.scanlator) } + val entry = Entry(merged, AnimeEntry(anime.id, anime.title, anime.source)) + preferences.edit { + putString(anime.id.toString(), json.encodeToString(entry)) + } + } + + fun getPendingEpisodes(anime: Anime): List { + val entry = preferences.getString(anime.id.toString(), null)?.let { decodeEntry(it) } + ?: return emptyList() + return entry.episodes.map { ep -> + Episode.create().copy(id = ep.id, name = ep.name, scanlator = ep.scanlator) + } + } + + fun removePendingDelete(anime: Anime) { + preferences.edit { + remove(anime.id.toString()) + } + } + + private fun decodeEntry(string: String): Entry? { + return try { + json.decodeFromString(string) + } catch (_: Exception) { + null + } + } + + @Serializable + private data class Entry( + val episodes: List, + val anime: AnimeEntry, + ) + + @Serializable + private data class EpisodeEntry( + val id: Long, + val name: String, + val scanlator: String?, + ) + + @Serializable + private data class AnimeEntry( + val id: Long, + val title: String, + val source: Long, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadProvider.kt new file mode 100644 index 0000000000..846f446e2a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadProvider.kt @@ -0,0 +1,124 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.util.storage.DiskUtil +import logcat.LogPriority +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.core.common.storage.displayablePath +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.episode.model.Episode +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.storage.service.StorageManager +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.IOException + +class AnimeDownloadProvider( + private val context: Context, + private val storageManager: StorageManager = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) { + + private val downloadsDir: UniFile? + get() = storageManager.getAnimeDownloadsDirectory() + + internal fun getAnimeDir(animeTitle: String, source: AnimeSource): Result { + val downloadsDir = downloadsDir + if (downloadsDir == null) { + logcat(LogPriority.ERROR) { "Failed to create anime download directory" } + return Result.failure( + IOException(context.stringResource(MR.strings.storage_failed_to_create_download_directory)), + ) + } + + val sourceDirName = getSourceDirName(source) + val sourceDir = downloadsDir.createDirectory(sourceDirName) + if (sourceDir == null) { + val displayablePath = downloadsDir.displayablePath + "/$sourceDirName" + logcat(LogPriority.ERROR) { "Failed to create source download directory: $displayablePath" } + return Result.failure( + IOException(context.stringResource(MR.strings.storage_failed_to_create_directory, displayablePath)), + ) + } + + val animeDirName = getAnimeDirName(animeTitle) + val animeDir = sourceDir.createDirectory(animeDirName) + if (animeDir == null) { + val displayablePath = sourceDir.displayablePath + "/$animeDirName" + logcat(LogPriority.ERROR) { "Failed to create anime download directory: $displayablePath" } + return Result.failure( + IOException(context.stringResource(MR.strings.storage_failed_to_create_directory, displayablePath)), + ) + } + + return Result.success(animeDir) + } + + fun findSourceDir(source: AnimeSource): UniFile? { + return downloadsDir?.findFile(getSourceDirName(source)) + } + + fun findAnimeDir(animeTitle: String, source: AnimeSource): UniFile? { + val sourceDir = findSourceDir(source) + return sourceDir?.findFile(getAnimeDirName(animeTitle)) + } + + fun findEpisodeDir( + episodeName: String, + episodeScanlator: String?, + animeTitle: String, + source: AnimeSource, + ): UniFile? { + val animeDir = findAnimeDir(animeTitle, source) + return getValidEpisodeDirNames(episodeName, episodeScanlator).asSequence() + .mapNotNull { animeDir?.findFile(it) } + .firstOrNull() + } + + fun findEpisodeDirs(episodes: List, anime: Anime, source: AnimeSource): Pair> { + val animeDir = findAnimeDir(anime.title, source) ?: return null to emptyList() + return animeDir to episodes.mapNotNull { episode -> + getValidEpisodeDirNames(episode.name, episode.scanlator).asSequence() + .mapNotNull { animeDir.findFile(it) } + .firstOrNull() + } + } + + fun getSourceDirName(source: AnimeSource): String { + return DiskUtil.buildValidFilename( + source.toString(), + disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(), + ) + } + + fun getAnimeDirName(animeTitle: String): String { + return DiskUtil.buildValidFilename( + animeTitle, + disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(), + ) + } + + fun getEpisodeDirName(episodeName: String, episodeScanlator: String?): String { + var dirName = episodeName.ifBlank { "Episode" } + if (!episodeScanlator.isNullOrBlank()) { + dirName = "${episodeScanlator}_$dirName" + } + return DiskUtil.buildValidFilename( + dirName, + disallowNonAscii = libraryPreferences.disallowNonAsciiFilenames().get(), + ) + } + + private fun getValidEpisodeDirNames(episodeName: String, episodeScanlator: String?): List { + val episodeDirName = getEpisodeDirName(episodeName, episodeScanlator) + return listOf(episodeDirName) + } + + companion object { + const val TMP_DIR_SUFFIX = "_tmp" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadStore.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadStore.kt new file mode 100644 index 0000000000..9a68cfa409 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloadStore.kt @@ -0,0 +1,95 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import androidx.core.content.edit +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import tachiyomi.domain.anime.interactor.GetAnime +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.animesource.service.AnimeSourceManager +import tachiyomi.domain.episode.interactor.GetEpisode +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AnimeDownloadStore( + context: Context, + private val animeSourceManager: AnimeSourceManager = Injekt.get(), + private val json: Json = Injekt.get(), + private val getAnime: GetAnime = Injekt.get(), + private val getEpisode: GetEpisode = Injekt.get(), +) { + + private val preferences = context.getSharedPreferences("active_anime_downloads", Context.MODE_PRIVATE) + + private var counter = 0 + + fun addAll(downloads: List) { + preferences.edit { + downloads.forEach { putString(getKey(it), serialize(it)) } + } + } + + fun remove(download: AnimeDownload) { + preferences.edit { + remove(getKey(download)) + } + } + + fun removeAll(downloads: List) { + preferences.edit { + downloads.forEach { remove(getKey(it)) } + } + } + + fun clear() { + preferences.edit { + clear() + } + } + + private fun getKey(download: AnimeDownload): String { + return download.episode.id.toString() + } + + suspend fun restore(): List { + val objs = preferences.all + .mapNotNull { it.value as? String } + .mapNotNull { deserialize(it) } + .sortedBy { it.order } + + val downloads = mutableListOf() + if (objs.isNotEmpty()) { + val cachedAnime = mutableMapOf() + for ((animeId, episodeId) in objs) { + val anime = cachedAnime.getOrPut(animeId) { + getAnime.await(animeId) + } ?: continue + val source = animeSourceManager.get(anime.source) as? AnimeHttpSource ?: continue + val episode = getEpisode.await(episodeId) ?: continue + downloads.add(AnimeDownload(source, anime, episode)) + } + } + + clear() + return downloads + } + + private fun serialize(download: AnimeDownload): String { + val obj = AnimeDownloadObject(download.anime.id, download.episode.id, counter++) + return json.encodeToString(obj) + } + + private fun deserialize(string: String): AnimeDownloadObject? { + return try { + json.decodeFromString(string) + } catch (e: Exception) { + null + } + } +} + +@Serializable +private data class AnimeDownloadObject(val animeId: Long, val episodeId: Long, val order: Int) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloader.kt b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloader.kt new file mode 100644 index 0000000000..6a4d95f95e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/animedownload/AnimeDownloader.kt @@ -0,0 +1,692 @@ +package eu.kanade.tachiyomi.data.animedownload + +import android.content.Context +import com.arthenica.ffmpegkit.FFmpegKitConfig +import com.arthenica.ffmpegkit.FFmpegSession +import com.arthenica.ffmpegkit.FFprobeSession +import com.arthenica.ffmpegkit.ReturnCode +import com.arthenica.ffmpegkit.StatisticsCallback +import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.SerializableVideo.Companion.serialize +import eu.kanade.tachiyomi.animesource.model.Track +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload +import eu.kanade.tachiyomi.data.torrentServer.service.TorrentServerService +import eu.kanade.tachiyomi.network.NetworkHelper +import eu.kanade.tachiyomi.torrentServer.TorrentServerApi +import eu.kanade.tachiyomi.torrentServer.TorrentServerUtils +import eu.kanade.tachiyomi.source.isSourceForTorrents +import eu.kanade.tachiyomi.ui.player.PlayerViewModel +import eu.kanade.tachiyomi.network.ProgressListener +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.toFFmpegString +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import logcat.LogPriority +import okhttp3.Headers +import okhttp3.Request +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.system.logcat +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.animesource.service.AnimeSourceManager +import tachiyomi.domain.episode.model.Episode +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.atomic.AtomicBoolean + +class AnimeDownloader( + private val context: Context, + private val provider: AnimeDownloadProvider, + private val cache: AnimeDownloadCache, + private val animeSourceManager: AnimeSourceManager = Injekt.get(), + private val networkHelper: NetworkHelper = Injekt.get(), +) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val store = AnimeDownloadStore(context) + private val notifier by lazy { AnimeDownloadNotifier(context) } + private val downloadClient by lazy { + networkHelper.client.newBuilder() + .callTimeout(java.time.Duration.ofMinutes(10)) + .readTimeout(java.time.Duration.ofMinutes(5)) + .build() + } + + private val _queueState = MutableStateFlow>(emptyList()) + private val _stateVersion = MutableStateFlow(0L) + + val queueState = _queueState.asStateFlow() + val stateVersion = _stateVersion.asStateFlow() + + private val isFFmpegRunning = AtomicBoolean(false) + @Volatile + private var currentFFmpegSession: FFmpegSession? = null + + private fun notifyQueueChanged() { + _stateVersion.update { it + 1 } + } + + private var downloaderJob: Job? = null + + val isRunning: Boolean + get() = downloaderJob?.isActive == true + + suspend fun start(): Boolean { + if (downloaderJob?.isActive == true) { + logcat(LogPriority.INFO) { "AnimeDownloader: already running" } + return true + } + + val queuedDownloads = _queueState.value.filter { it.status == AnimeDownload.State.QUEUE } + if (queuedDownloads.isEmpty()) { + val restored = store.restore() + if (restored.isEmpty()) { + logcat(LogPriority.INFO) { "AnimeDownloader: nothing to start (queue empty, no restore)" } + return false + } + restored.forEach { it.status = AnimeDownload.State.QUEUE } + addAllToQueue(restored) + } + + logcat(LogPriority.INFO) { "AnimeDownloader: starting processQueue with ${_queueState.value.size} items" } + downloaderJob = scope.launchIO { + processQueue() + } + return true + } + + fun stop(reason: String? = null) { + cancelFFmpeg() + downloaderJob?.cancel() + downloaderJob = null + + if (_queueState.value.isEmpty()) { + notifier.onComplete() + return + } + + if (reason != null) { + notifier.onPaused() + } else { + notifier.onComplete() + } + + _queueState.value + .filter { it.status == AnimeDownload.State.DOWNLOADING } + .forEach { it.status = AnimeDownload.State.QUEUE } + notifyQueueChanged() + } + + fun pause() { + cancelFFmpeg() + downloaderJob?.cancel() + downloaderJob = null + _queueState.value + .filter { it.status == AnimeDownload.State.DOWNLOADING } + .forEach { it.status = AnimeDownload.State.QUEUE } + notifyQueueChanged() + notifier.onPaused() + } + + fun clearQueue() { + store.clear() + _queueState.update { emptyList() } + notifier.dismissProgress() + } + + fun queueEpisodes(anime: Anime, episodes: List, videos: List, autoStart: Boolean) { + val source = animeSourceManager.get(anime.source) as? AnimeHttpSource + if (source == null) { + logcat(LogPriority.ERROR) { "AnimeDownloader: source ${anime.source} not found or not AnimeHttpSource" } + return + } + + // Remove any errored items for these episodes so they can be re-queued + val episodeIds = episodes.map { it.id }.toSet() + _queueState.update { queue -> + queue.filter { it.episode.id !in episodeIds || it.status != AnimeDownload.State.ERROR } + } + + val episodesToQueue = episodes.zip(videos).filter { (episode, _) -> + val isDuplicate = _queueState.value.any { it.episode.id == episode.id } + val isDownloaded = provider.findEpisodeDir( + episode.name, + episode.scanlator, + anime.title, + source, + ) != null + !isDuplicate && !isDownloaded + } + + if (episodesToQueue.isEmpty()) { + logcat(LogPriority.WARN) { "AnimeDownloader: nothing to queue (duplicate or already downloaded)" } + return + } + + val downloads = episodesToQueue.map { (episode, video) -> + AnimeDownload(source, anime, episode, video) + } + + downloads.forEach { it.status = AnimeDownload.State.QUEUE } + addAllToQueue(downloads) + store.addAll(downloads) + logcat(LogPriority.INFO) { "AnimeDownloader: queued ${downloads.size} episode(s)" } + + if (autoStart) { + AnimeDownloadJob.start(context) + } + } + + fun removeFromQueue(episodes: List) { + val episodeIds = episodes.map { it.id }.toSet() + val removed = _queueState.value.filter { it.episode.id in episodeIds } + store.removeAll(removed) + _queueState.update { queue -> queue.filter { it.episode.id !in episodeIds } } + } + + fun removeFromQueue(anime: Anime) { + val removed = _queueState.value.filter { it.anime.id == anime.id } + store.removeAll(removed) + _queueState.update { queue -> queue.filter { it.anime.id != anime.id } } + } + + private fun addAllToQueue(downloads: List) { + _queueState.update { it + downloads } + } + + private fun removeFromQueueState(download: AnimeDownload) { + _queueState.update { queue -> queue.filter { it.episode.id != download.episode.id } } + } + + private suspend fun processQueue() { + logcat(LogPriority.INFO) { "AnimeDownloader: processQueue started" } + supervisorScope { + while (true) { + val download = _queueState.value.firstOrNull { it.status == AnimeDownload.State.QUEUE } + ?: break + + logcat(LogPriority.INFO) { "AnimeDownloader: downloading ${download.episode.name}" } + try { + downloadEpisode(download) + store.remove(download) + removeFromQueueState(download) + logcat(LogPriority.INFO) { "AnimeDownloader: completed ${download.episode.name}" } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to download ${download.episode.name}" } + download.status = AnimeDownload.State.ERROR + notifyQueueChanged() + notifier.onError(e.message, download.episode.name, download.anime.title) + store.remove(download) + } + } + } + + notifier.onComplete() + stop() + } + + private suspend fun downloadEpisode(download: AnimeDownload) { + if (download.video == null) { + resolveVideo(download) + } + + val video = download.video + ?: throw IllegalStateException("Could not resolve video for episode ${download.episode.name}") + + val videoUrl = video.videoUrl + if (videoUrl.isBlank()) { + throw IllegalStateException("Video URL is blank for episode ${download.episode.name}") + } + + download.status = AnimeDownload.State.DOWNLOADING + notifyQueueChanged() + notifier.onProgressChange(download) + + val animeDir = provider.getAnimeDir(download.anime.title, download.source).getOrThrow() + + val availSpace = DiskUtil.getAvailableStorageSpace(animeDir) + if (availSpace != -1L && availSpace < MIN_DISK_SPACE) { + throw IllegalStateException("Insufficient storage space (${availSpace / 1024 / 1024}MB free, ${MIN_DISK_SPACE / 1024 / 1024}MB required)") + } + + val episodeDirName = provider.getEpisodeDirName(download.episode.name, download.episode.scanlator) + val tmpDirName = episodeDirName + AnimeDownloadProvider.TMP_DIR_SUFFIX + val tmpDir = animeDir.createDirectory(tmpDirName) + ?: throw IllegalStateException("Failed to create temp directory") + + try { + DiskUtil.createNoMediaFile(tmpDir, context) + + if (PlayerViewModel.isTorrentUrl(videoUrl)) { + resolveTorrentVideo(video) + } + + if (video.videoUrl.startsWith("http")) { + ffmpegDownloadVideo(video, tmpDir, download) + } else { + downloadVideoFile(video, tmpDir, download) + } + + downloadSubtitles(video, tmpDir) + + writeMetadata(video, tmpDir) + + val finalDir = animeDir.findFile(episodeDirName) + finalDir?.delete() + tmpDir.renameTo(episodeDirName) + + download.status = AnimeDownload.State.DOWNLOADED + download.progress = 100 + cache.addEpisode(episodeDirName, animeDir, download.anime) + } catch (e: Exception) { + tmpDir.delete() + throw e + } + } + + private suspend fun resolveVideo(download: AnimeDownload) { + withContext(Dispatchers.IO) { + val source = download.source + if (source.isSourceForTorrents()) { + TorrentServerService.start() + if (TorrentServerService.wait(10)) { + TorrentServerUtils.setTrackersList() + } + } + val episode = download.episode + val sEpisode = SEpisode.create().apply { + url = episode.url + name = episode.name + date_upload = episode.dateUpload + episode_number = episode.episodeNumber.toFloat() + scanlator = episode.scanlator + } + + val videos = mutableListOf