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