Merge upstream

This commit is contained in:
Quickdesh 2022-11-06 19:41:13 -05:00
parent 9b4b4da7ce
commit ff08f5fa8f
415 changed files with 13815 additions and 13866 deletions

View file

@ -12,7 +12,6 @@ plugins {
if (gradle.startParameter.taskRequests.toString().contains("Standard")) {
apply<com.google.gms.googleservices.GoogleServicesPlugin>()
apply<com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsPlugin>()
}
shortcutHelper.setFilePath("./shortcuts.xml")
@ -29,7 +28,7 @@ android {
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
versionCode = 90
versionName = "0.13.5.0"
versionName = "0.14.0.0"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getGitSha()}\"")
@ -296,7 +295,6 @@ dependencies {
// Crash reports/analytics
implementation(libs.acra.http)
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
// Shizuku
implementation(libs.bundles.shizuku)

View file

@ -141,7 +141,7 @@
android:exported="false" />
<activity
android:name=".extension.util.AnimeExtensionInstallActivity"
android:name=".animeextension.util.AnimeExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="false" />
@ -225,12 +225,6 @@
android:name=".data.notification.NotificationReceiver"
android:exported="false" />
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON"/>
</intent-filter>
</receiver>
<receiver
android:name=".glance.UpdatesGridGlanceReceiver"
android:enabled="@bool/glance_appwidget_available"
@ -258,7 +252,7 @@
android:exported="false" />
<service
android:name=".data.download.AnimeDownloadService"
android:name=".data.animedownload.AnimeDownloadService"
android:exported="false" />
<service
@ -272,7 +266,7 @@
<service android:name=".extension.util.ExtensionInstallService"
android:exported="false" />
<service android:name=".extension.util.AnimeExtensionInstallService"
<service android:name=".animeextension.util.AnimeExtensionInstallService"
android:exported="false" />
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View file

@ -1,40 +1,17 @@
package eu.kanade.data.anime
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.episode.model.Episode
import eu.kanade.tachiyomi.data.database.models.AnimelibAnime
import eu.kanade.domain.animelib.model.AnimelibAnime
import eu.kanade.tachiyomi.source.model.UpdateStrategy
val animeMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long) -> Anime =
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewer, episodeFlags, coverLastModified, dateAdded ->
val animeMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy) -> Anime =
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy ->
Anime(
id = id,
source = source,
favorite = favorite,
lastUpdate = lastUpdate ?: 0,
dateAdded = dateAdded,
viewerFlags = viewer,
episodeFlags = episodeFlags,
coverLastModified = coverLastModified,
url = url,
title = title,
artist = artist,
author = author,
description = description,
genre = genre,
status = status,
thumbnailUrl = thumbnailUrl,
initialized = initialized,
)
}
val animeEpisodeMapper: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, Long, Long, String, String, String?, Boolean, Boolean, Long, Long, Float, Long, Long, Long) -> Pair<Anime, Episode> =
{ _id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, next_update, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, episodeId, animeId, chapterUrl, name, scanlator, seen, bookmark, lastSecondSeen, totalSeconds, episodeNumber, sourceOrder, dateFetch, dateUpload ->
Anime(
id = _id,
source = source,
favorite = favorite,
lastUpdate = lastUpdate ?: 0,
dateAdded = dateAdded,
viewerFlags = viewerFlags,
episodeFlags = episodeFlags,
coverLastModified = coverLastModified,
@ -46,46 +23,40 @@ val animeEpisodeMapper: (Long, Long, String, String?, String?, String?, List<Str
genre = genre,
status = status,
thumbnailUrl = thumbnailUrl,
updateStrategy = updateStrategy,
initialized = initialized,
) to Episode(
id = episodeId,
animeId = animeId,
seen = seen,
bookmark = bookmark,
lastSecondSeen = lastSecondSeen,
totalSeconds = totalSeconds,
dateFetch = dateFetch,
sourceOrder = sourceOrder,
url = chapterUrl,
name = name,
dateUpload = dateUpload,
episodeNumber = episodeNumber,
scanlator = scanlator,
)
}
val animelibAnime: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, Long, Long, Long) -> AnimelibAnime =
{ _id, source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, episode_flags, cover_last_modified, date_added, unseen_count, seen_count, category ->
AnimelibAnime().apply {
this.id = _id
this.source = source
this.url = url
this.artist = artist
this.author = author
this.description = description
this.genre = genre?.joinToString()
this.title = title
this.status = status.toInt()
this.thumbnail_url = thumbnail_url
this.favorite = favorite
this.last_update = last_update ?: 0
this.initialized = initialized
this.viewer_flags = viewer.toInt()
this.episode_flags = episode_flags.toInt()
this.cover_last_modified = cover_last_modified
this.date_added = date_added
this.unseenCount = unseen_count.toInt()
this.seenCount = seen_count.toInt()
this.category = category.toInt()
}
val animelibAnime: (Long, Long, String, String?, String?, String?, List<String>?, String, Long, String?, Boolean, Long?, Long?, Boolean, Long, Long, Long, Long, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long) -> AnimelibAnime =
{ id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy, totalCount, seenCount, latestUpload, episodeFetchedAt, lastSeen, bookmarkCount, category ->
AnimelibAnime(
anime = animeMapper(
id,
source,
url,
artist,
author,
description,
genre,
title,
status,
thumbnailUrl,
favorite,
lastUpdate,
nextUpdate,
initialized,
viewerFlags,
episodeFlags,
coverLastModified,
dateAdded,
updateStrategy,
),
category = category,
totalEpisodes = totalCount,
seenCount = seenCount,
bookmarkCount = bookmarkCount,
latestUpload = latestUpload,
episodeFetchedAt = episodeFetchedAt,
lastSeen = lastSeen,
)
}

View file

@ -2,12 +2,13 @@ package eu.kanade.data.anime
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.data.listOfStringsAdapter
import eu.kanade.data.toLong
import eu.kanade.data.updateStrategyAdapter
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.model.AnimeUpdate
import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.tachiyomi.data.database.models.AnimelibAnime
import eu.kanade.domain.animelib.model.AnimelibAnime
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toLong
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
@ -24,7 +25,11 @@ class AnimeRepositoryImpl(
}
override suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime? {
return handler.awaitOneOrNull { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) }
return handler.awaitOneOrNull(inTransaction = true) { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) }
}
override fun getAnimeByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Anime?> {
return handler.subscribeToOneOrNull { animesQueries.getAnimeByUrlAndSource(url, sourceId, animeMapper) }
}
override suspend fun getFavorites(): List<Anime> {
@ -32,11 +37,11 @@ class AnimeRepositoryImpl(
}
override suspend fun getAnimelibAnime(): List<AnimelibAnime> {
return handler.awaitList { animesQueries.getAnimelib(animelibAnime) }
return handler.awaitList { animelibViewQueries.animelib(animelibAnime) }
}
override fun getAnimelibAnimeAsFlow(): Flow<List<AnimelibAnime>> {
return handler.subscribeToList { animesQueries.getAnimelib(animelibAnime) }
return handler.subscribeToList { animelibViewQueries.animelib(animelibAnime) }
}
override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Anime>> {
@ -69,7 +74,7 @@ class AnimeRepositoryImpl(
}
override suspend fun insert(anime: Anime): Long? {
return handler.awaitOneOrNull {
return handler.awaitOneOrNull(inTransaction = true) {
animesQueries.insert(
source = anime.source,
url = anime.url,
@ -88,6 +93,7 @@ class AnimeRepositoryImpl(
episodeFlags = anime.episodeFlags,
coverLastModified = anime.coverLastModified,
dateAdded = anime.dateAdded,
updateStrategy = anime.updateStrategy,
)
animesQueries.selectLastInsertedRowId()
}
@ -103,9 +109,9 @@ class AnimeRepositoryImpl(
}
}
override suspend fun updateAll(values: List<AnimeUpdate>): Boolean {
override suspend fun updateAll(animeUpdates: List<AnimeUpdate>): Boolean {
return try {
partialUpdate(*values.toTypedArray())
partialUpdate(*animeUpdates.toTypedArray())
true
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
@ -113,9 +119,9 @@ class AnimeRepositoryImpl(
}
}
private suspend fun partialUpdate(vararg values: AnimeUpdate) {
private suspend fun partialUpdate(vararg animeUpdates: AnimeUpdate) {
handler.await(inTransaction = true) {
values.forEach { value ->
animeUpdates.forEach { value ->
animesQueries.update(
source = value.source,
url = value.url,
@ -134,6 +140,7 @@ class AnimeRepositoryImpl(
coverLastModified = value.coverLastModified,
dateAdded = value.dateAdded,
animeId = value.id,
updateStrategy = value.updateStrategy?.let(updateStrategyAdapter::encode),
)
}
}

View file

@ -1,28 +1,21 @@
package eu.kanade.data.animehistory
import androidx.paging.PagingSource
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.data.anime.animeMapper
import eu.kanade.data.episode.episodeMapper
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate
import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
import eu.kanade.domain.episode.model.Episode
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
class AnimeHistoryRepositoryImpl(
private val handler: AnimeDatabaseHandler,
) : AnimeHistoryRepository {
override fun getHistory(query: String): PagingSource<Long, AnimeHistoryWithRelations> {
return handler.subscribeToPagingSource(
countQuery = { animehistoryViewQueries.countHistory(query) },
queryProvider = { limit, offset ->
animehistoryViewQueries.animehistory(query, limit, offset, animehistoryWithRelationsMapper)
},
)
override fun getHistory(query: String): Flow<List<AnimeHistoryWithRelations>> {
return handler.subscribeToList {
animehistoryViewQueries.animehistory(query, animehistoryWithRelationsMapper)
}
}
override suspend fun getLastHistory(): AnimeHistoryWithRelations? {
@ -31,45 +24,6 @@ class AnimeHistoryRepositoryImpl(
}
}
override suspend fun getNextEpisode(animeId: Long, episodeId: Long): Episode? {
val episode = handler.awaitOne { episodesQueries.getEpisodeById(episodeId, episodeMapper) }
val anime = handler.awaitOne { animesQueries.getAnimeById(animeId, animeMapper) }
if (!episode.seen) {
return episode
}
val sortFunction: (Episode, Episode) -> Int = when (anime.sorting) {
Anime.EPISODE_SORTING_SOURCE -> { c1, c2 -> c2.sourceOrder.compareTo(c1.sourceOrder) }
Anime.EPISODE_SORTING_NUMBER -> { c1, c2 -> c1.episodeNumber.compareTo(c2.episodeNumber) }
Anime.EPISODE_SORTING_UPLOAD_DATE -> { c1, c2 -> c1.dateUpload.compareTo(c2.dateUpload) }
else -> throw NotImplementedError("Unknown sorting method")
}
val episodes = handler.awaitList { episodesQueries.getEpisodesByAnimeId(animeId, episodeMapper) }
.sortedWith(sortFunction)
val currEpisodeIndex = episodes.indexOfFirst { episode.id == it.id }
return when (anime.sorting) {
Anime.EPISODE_SORTING_SOURCE -> episodes.getOrNull(currEpisodeIndex + 1)
Anime.EPISODE_SORTING_NUMBER -> {
val episodeNumber = episode.episodeNumber
((currEpisodeIndex + 1) until episodes.size)
.map { episodes[it] }
.firstOrNull {
it.episodeNumber > episodeNumber &&
it.episodeNumber <= episodeNumber + 1
}
}
Anime.EPISODE_SORTING_UPLOAD_DATE -> {
episodes.drop(currEpisodeIndex + 1)
.firstOrNull { it.dateUpload >= episode.dateUpload }
}
else -> throw NotImplementedError("Unknown sorting method")
}
}
override suspend fun resetHistory(historyId: Long) {
try {
handler.await { animehistoryQueries.resetAnimeHistoryById(historyId) }

View file

@ -0,0 +1,23 @@
package eu.kanade.data.animesource
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.domain.animesource.model.AnimeSourceData
import eu.kanade.domain.animesource.repository.AnimeSourceDataRepository
import kotlinx.coroutines.flow.Flow
class AnimeSourceDataRepositoryImpl(
private val handler: AnimeDatabaseHandler,
) : AnimeSourceDataRepository {
override fun subscribeAll(): Flow<List<AnimeSourceData>> {
return handler.subscribeToList { animesourcesQueries.findAll(animesourceDataMapper) }
}
override suspend fun getSourceData(id: Long): AnimeSourceData? {
return handler.awaitOneOrNull { animesourcesQueries.findOne(id, animesourceDataMapper) }
}
override suspend fun upsertSourceData(id: Long, lang: String, name: String) {
handler.await { animesourcesQueries.upsert(id, lang, name) }
}
}

View file

@ -11,7 +11,7 @@ val animesourceMapper: (eu.kanade.tachiyomi.animesource.AnimeSource) -> AnimeSou
source.lang,
source.name,
supportsLatest = false,
isStub = source is AnimeSourceManager.StubSource,
isStub = source is AnimeSourceManager.StubAnimeSource,
)
}

View file

@ -0,0 +1,63 @@
package eu.kanade.data.animesource
import androidx.paging.PagingState
import eu.kanade.data.episode.NoEpisodesException
import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.withIOContext
abstract class AnimeSourcePagingSource(
protected val source: AnimeCatalogueSource,
) : AnimeSourcePagingSourceType() {
abstract suspend fun requestNextPage(currentPage: Int): AnimesPage
override suspend fun load(params: LoadParams<Long>): LoadResult<Long, SAnime> {
val page = params.key ?: 1
val animesPage = try {
withIOContext {
requestNextPage(page.toInt())
.takeIf { it.animes.isNotEmpty() }
?: throw NoEpisodesException()
}
} catch (e: Exception) {
return LoadResult.Error(e)
}
return LoadResult.Page(
data = animesPage.animes,
prevKey = null,
nextKey = if (animesPage.hasNextPage) page + 1 else null,
)
}
override fun getRefreshKey(state: PagingState<Long, SAnime>): Long? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey ?: anchorPage?.nextKey
}
}
}
class AnimeSourceSearchPagingSource(source: AnimeCatalogueSource, val query: String, val filters: AnimeFilterList) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
return source.fetchSearchAnime(currentPage, query, filters).awaitSingle()
}
}
class AnimeSourcePopularPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
return source.fetchPopularAnime(currentPage).awaitSingle()
}
}
class AnimeSourceLatestPagingSource(source: AnimeCatalogueSource) : AnimeSourcePagingSource(source) {
override suspend fun requestNextPage(currentPage: Int): AnimesPage {
return source.fetchLatestUpdates(currentPage).awaitSingle()
}
}

View file

@ -2,13 +2,15 @@ package eu.kanade.data.animesource
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.model.AnimeSourceData
import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType
import eu.kanade.domain.animesource.model.AnimeSourceWithCount
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource
class AnimeSourceRepositoryImpl(
private val sourceManager: AnimeSourceManager,
@ -41,21 +43,32 @@ class AnimeSourceRepositoryImpl(
}
}
override fun getSourcesWithNonLibraryAnime(): Flow<List<Pair<LoadedAnimeSource, Long>>> {
override fun getSourcesWithNonLibraryAnime(): Flow<List<AnimeSourceWithCount>> {
val sourceIdWithNonLibraryAnime = handler.subscribeToList { animesQueries.getSourceIdsWithNonLibraryAnime() }
return sourceIdWithNonLibraryAnime.map { sourceId ->
sourceId.map { (sourceId, count) ->
val source = sourceManager.getOrStub(sourceId)
source to count
AnimeSourceWithCount(animesourceMapper(source), count)
}
}
}
override suspend fun getAnimeSourceData(id: Long): AnimeSourceData? {
return handler.awaitOneOrNull { animesourcesQueries.getAnimeSourceData(id, animesourceDataMapper) }
override fun search(
sourceId: Long,
query: String,
filterList: AnimeFilterList,
): AnimeSourcePagingSourceType {
val source = sourceManager.get(sourceId) as AnimeCatalogueSource
return AnimeSourceSearchPagingSource(source, query, filterList)
}
override suspend fun upsertAnimeSourceData(id: Long, lang: String, name: String) {
handler.await { animesourcesQueries.upsert(id, lang, name) }
override fun getPopular(sourceId: Long): AnimeSourcePagingSourceType {
val source = sourceManager.get(sourceId) as AnimeCatalogueSource
return AnimeSourcePopularPagingSource(source)
}
override fun getLatest(sourceId: Long): AnimeSourcePagingSourceType {
val source = sourceManager.get(sourceId) as AnimeCatalogueSource
return AnimeSourceLatestPagingSource(source)
}
}

View file

@ -44,9 +44,9 @@ class AnimeTrackRepositoryImpl(
insertValues(*tracks.toTypedArray())
}
private suspend fun insertValues(vararg values: AnimeTrack) {
private suspend fun insertValues(vararg tracks: AnimeTrack) {
handler.await(inTransaction = true) {
values.forEach { animeTrack ->
tracks.forEach { animeTrack ->
anime_syncQueries.insert(
animeId = animeTrack.animeId,
syncId = animeTrack.syncId,

View file

@ -0,0 +1,26 @@
package eu.kanade.data.animeupdates
import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations
import eu.kanade.domain.manga.model.MangaCover
val updateWithRelationMapper: (Long, String, Long, String, String?, Boolean, Boolean, Long, Boolean, String?, Long, Long, Long) -> AnimeUpdatesWithRelations = {
animeId, animeTitle, episodeId, episodeName, scanlator, seen, bookmark, sourceId, favorite, thumbnailUrl, coverLastModified, _, dateFetch ->
AnimeUpdatesWithRelations(
animeId = animeId,
animeTitle = animeTitle,
episodeId = episodeId,
episodeName = episodeName,
scanlator = scanlator,
seen = seen,
bookmark = bookmark,
sourceId = sourceId,
dateFetch = dateFetch,
coverData = MangaCover(
mangaId = animeId,
sourceId = sourceId,
isMangaFavorite = favorite,
url = thumbnailUrl,
lastModified = coverLastModified,
),
)
}

View file

@ -0,0 +1,17 @@
package eu.kanade.data.animeupdates
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations
import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository
import kotlinx.coroutines.flow.Flow
class AnimeUpdatesRepositoryImpl(
val databaseHandler: AnimeDatabaseHandler,
) : AnimeUpdatesRepository {
override fun subscribeAll(after: Long): Flow<List<AnimeUpdatesWithRelations>> {
return databaseHandler.subscribeToList {
animeupdatesViewQueries.animeupdates(after, updateWithRelationMapper)
}
}
}

View file

@ -4,13 +4,17 @@ import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.category.repository.DuplicateNameException
import eu.kanade.tachiyomi.mi.AnimeDatabase
import kotlinx.coroutines.flow.Flow
class CategoryRepositoryImplAnime(
private val handler: AnimeDatabaseHandler,
) : CategoryRepositoryAnime {
override suspend fun get(id: Long): Category? {
return handler.awaitOneOrNull { categoriesQueries.getCategory(id, categoryMapper) }
}
override suspend fun getAll(): List<Category> {
return handler.awaitList { categoriesQueries.getCategories(categoryMapper) }
}
@ -31,29 +35,43 @@ class CategoryRepositoryImplAnime(
}
}
@Throws(DuplicateNameException::class)
override suspend fun insert(name: String, order: Long) {
if (checkDuplicateName(name)) throw DuplicateNameException(name)
override suspend fun insert(category: Category) {
handler.await {
categoriesQueries.insert(
name = name,
order = order,
flags = 0L,
name = category.name,
order = category.order,
flags = category.flags,
)
}
}
@Throws(DuplicateNameException::class)
override suspend fun update(payload: CategoryUpdate) {
if (payload.name != null && checkDuplicateName(payload.name)) throw DuplicateNameException(payload.name)
override suspend fun updatePartial(update: CategoryUpdate) {
handler.await {
updatePartialBlocking(update)
}
}
override suspend fun updatePartial(updates: List<CategoryUpdate>) {
handler.await(inTransaction = true) {
for (update in updates) {
updatePartialBlocking(update)
}
}
}
private fun AnimeDatabase.updatePartialBlocking(update: CategoryUpdate) {
categoriesQueries.update(
name = payload.name,
order = payload.order,
flags = payload.flags,
categoryId = payload.id,
name = update.name,
order = update.order,
flags = update.flags,
categoryId = update.id,
)
}
override suspend fun updateAllFlags(flags: Long?) {
handler.await {
categoriesQueries.updateAllFlags(flags)
}
}
override suspend fun delete(categoryId: Long) {
@ -63,10 +81,4 @@ class CategoryRepositoryImplAnime(
)
}
}
override suspend fun checkDuplicateName(name: String): Boolean {
return handler
.awaitList { categoriesQueries.getCategories() }
.any { it.name == name }
}
}

View file

@ -0,0 +1,47 @@
package eu.kanade.data.episode
object CleanupEpisodeName {
fun await(episodeName: String, animeTitle: String): String {
return episodeName
.trim()
.removePrefix(animeTitle)
.trim(*EPISODE_TRIM_CHARS)
}
private val EPISODE_TRIM_CHARS = arrayOf(
// Whitespace
' ',
'\u0009',
'\u000A',
'\u000B',
'\u000C',
'\u000D',
'\u0020',
'\u0085',
'\u00A0',
'\u1680',
'\u2000',
'\u2001',
'\u2002',
'\u2003',
'\u2004',
'\u2005',
'\u2006',
'\u2007',
'\u2008',
'\u2009',
'\u200A',
'\u2028',
'\u2029',
'\u202F',
'\u205F',
'\u3000',
// Separators
'-',
'_',
',',
':',
).toCharArray()
}

View file

@ -1,11 +1,11 @@
package eu.kanade.data.episode
import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.data.toLong
import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.EpisodeUpdate
import eu.kanade.domain.episode.repository.EpisodeRepository
import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toLong
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority
@ -83,6 +83,10 @@ class EpisodeRepositoryImpl(
return handler.awaitList { episodesQueries.getEpisodesByAnimeId(animeId, episodeMapper) }
}
override suspend fun getBookmarkedEpisodesByAnimeId(animeId: Long): List<Episode> {
return handler.awaitList { episodesQueries.getBookmarkedEpisodesByAnimeId(animeId, episodeMapper) }
}
override suspend fun getEpisodeById(id: Long): Episode? {
return handler.awaitOneOrNull { episodesQueries.getEpisodeById(id, episodeMapper) }
}

View file

@ -1,3 +0,0 @@
package eu.kanade.data.source
class NoResultsException : Exception()

View file

@ -1,6 +1,7 @@
package eu.kanade.data.source
import androidx.paging.PagingState
import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.source.model.SourcePagingSourceType
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList
@ -22,7 +23,7 @@ abstract class SourcePagingSource(
withIOContext {
requestNextPage(page.toInt())
.takeIf { it.mangas.isNotEmpty() }
?: throw NoResultsException()
?: throw NoChaptersException()
}
} catch (e: Exception) {
return LoadResult.Error(e)

View file

@ -2,8 +2,10 @@ package eu.kanade.domain
import eu.kanade.data.anime.AnimeRepositoryImpl
import eu.kanade.data.animehistory.AnimeHistoryRepositoryImpl
import eu.kanade.data.animesource.AnimeSourceDataRepositoryImpl
import eu.kanade.data.animesource.AnimeSourceRepositoryImpl
import eu.kanade.data.animetrack.AnimeTrackRepositoryImpl
import eu.kanade.data.animeupdates.AnimeUpdatesRepositoryImpl
import eu.kanade.data.category.CategoryRepositoryImpl
import eu.kanade.data.category.CategoryRepositoryImplAnime
import eu.kanade.data.chapter.ChapterRepositoryImpl
@ -13,10 +15,12 @@ import eu.kanade.data.manga.MangaRepositoryImpl
import eu.kanade.data.source.SourceDataRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.data.track.TrackRepositoryImpl
import eu.kanade.data.updates.UpdatesRepositoryImpl
import eu.kanade.domain.anime.interactor.GetAnime
import eu.kanade.domain.anime.interactor.GetAnimeWithEpisodes
import eu.kanade.domain.anime.interactor.GetAnimelibAnime
import eu.kanade.domain.anime.interactor.GetDuplicateLibraryAnime
import eu.kanade.domain.anime.interactor.NetworkToLocalAnime
import eu.kanade.domain.anime.interactor.SetAnimeEpisodeFlags
import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags
import eu.kanade.domain.anime.interactor.UpdateAnime
@ -24,47 +28,50 @@ import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionUpdates
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensions
import eu.kanade.domain.animehistory.interactor.DeleteAnimeHistoryTable
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionsByType
import eu.kanade.domain.animehistory.interactor.DeleteAllAnimeHistory
import eu.kanade.domain.animehistory.interactor.GetAnimeHistory
import eu.kanade.domain.animehistory.interactor.GetNextEpisode
import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryByAnimeId
import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryById
import eu.kanade.domain.animehistory.interactor.UpsertAnimeHistory
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
import eu.kanade.domain.animesource.interactor.GetAnimeSourceData
import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithFavoriteCount
import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithNonLibraryAnime
import eu.kanade.domain.animesource.interactor.GetEnabledAnimeSources
import eu.kanade.domain.animesource.interactor.GetLanguagesWithAnimeSources
import eu.kanade.domain.animesource.interactor.GetRemoteAnime
import eu.kanade.domain.animesource.interactor.ToggleAnimeSource
import eu.kanade.domain.animesource.interactor.ToggleAnimeSourcePin
import eu.kanade.domain.animesource.interactor.UpsertAnimeSourceData
import eu.kanade.domain.animesource.repository.AnimeSourceDataRepository
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.animetrack.interactor.DeleteAnimeTrack
import eu.kanade.domain.animetrack.interactor.GetAnimeTracks
import eu.kanade.domain.animetrack.interactor.GetTracksPerAnime
import eu.kanade.domain.animetrack.interactor.InsertAnimeTrack
import eu.kanade.domain.animetrack.repository.AnimeTrackRepository
import eu.kanade.data.updates.UpdatesRepositoryImpl
import eu.kanade.domain.animeupdates.interactor.GetAnimeUpdates
import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository
import eu.kanade.domain.category.interactor.CreateAnimeCategoryWithName
import eu.kanade.domain.category.interactor.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteAnimeCategory
import eu.kanade.domain.category.interactor.DeleteCategory
import eu.kanade.domain.category.interactor.DeleteCategoryAnime
import eu.kanade.domain.category.interactor.GetAnimeCategories
import eu.kanade.domain.category.interactor.GetCategories
import eu.kanade.domain.category.interactor.GetCategoriesAnime
import eu.kanade.domain.category.interactor.SetAnimeCategories
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.RenameAnimeCategory
import eu.kanade.domain.category.interactor.ReorderCategory
import eu.kanade.domain.category.interactor.RenameCategory
import eu.kanade.domain.category.interactor.ReorderAnimeCategory
import eu.kanade.domain.category.interactor.ResetCategoryFlags
import eu.kanade.domain.category.interactor.ReorderCategory
import eu.kanade.domain.category.interactor.ResetAnimeCategoryFlags
import eu.kanade.domain.category.interactor.SetDisplayModeForCategory
import eu.kanade.domain.category.interactor.ResetCategoryFlags
import eu.kanade.domain.category.interactor.SetAnimeCategories
import eu.kanade.domain.category.interactor.SetDisplayModeForAnimeCategory
import eu.kanade.domain.category.interactor.SetDisplayModeForCategory
import eu.kanade.domain.category.interactor.SetMangaCategories
import eu.kanade.domain.category.interactor.SetSortModeForAnimeCategory
import eu.kanade.domain.category.interactor.SetSortModeForCategory
import eu.kanade.domain.category.interactor.UpdateAnimeCategory
import eu.kanade.domain.category.interactor.UpdateCategory
import eu.kanade.domain.category.interactor.UpdateCategoryAnime
import eu.kanade.domain.category.repository.CategoryRepository
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.chapter.interactor.GetChapter
@ -79,6 +86,7 @@ import eu.kanade.domain.chapter.repository.ChapterRepository
import eu.kanade.domain.download.interactor.DeleteDownload
import eu.kanade.domain.episode.interactor.GetEpisode
import eu.kanade.domain.episode.interactor.GetEpisodeByAnimeId
import eu.kanade.domain.episode.interactor.SetAnimeDefaultEpisodeFlags
import eu.kanade.domain.episode.interactor.SetSeenStatus
import eu.kanade.domain.episode.interactor.ShouldUpdateDbEpisode
import eu.kanade.domain.episode.interactor.SyncEpisodesWithSource
@ -129,15 +137,14 @@ import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get
import eu.kanade.domain.anime.interactor.GetFavorites as GetFavoritesAnime
import eu.kanade.domain.anime.interactor.GetAnimeFavorites as GetFavoritesAnime
import eu.kanade.domain.anime.interactor.ResetViewerFlags as ResetViewerFlagsAnime
class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<CategoryRepositoryAnime> { CategoryRepositoryImplAnime(get()) }
addFactory { GetCategoriesAnime(get()) }
addFactory { GetAnimeCategories(get()) }
addFactory { ResetAnimeCategoryFlags(get(), get()) }
addFactory { SetDisplayModeForAnimeCategory(get(), get()) }
addFactory { SetSortModeForAnimeCategory(get(), get()) }
@ -190,7 +197,7 @@ class DomainModule : InjektModule {
addSingletonFactory<AnimeTrackRepository> { AnimeTrackRepositoryImpl(get()) }
addFactory { DeleteAnimeTrack(get()) }
addFactory { GetAnimeTracksPerAnime(get())
addFactory { GetTracksPerAnime(get()) }
addFactory { GetAnimeTracks(get()) }
addFactory { InsertAnimeTrack(get()) }
@ -200,15 +207,6 @@ class DomainModule : InjektModule {
addFactory { GetTracks(get()) }
addFactory { InsertTrack(get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
addFactory { GetChapterByMangaId(get()) }
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get()) }
addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) }
addSingletonFactory<EpisodeRepository> { EpisodeRepositoryImpl(get()) }
addFactory { GetEpisode(get()) }
addFactory { GetEpisodeByAnimeId(get()) }
@ -218,6 +216,15 @@ class DomainModule : InjektModule {
addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) }
addFactory { SyncEpisodesWithTrackServiceTwoWay(get(), get()) }
addSingletonFactory<ChapterRepository> { ChapterRepositoryImpl(get()) }
addFactory { GetChapter(get()) }
addFactory { GetChapterByMangaId(get()) }
addFactory { UpdateChapter(get()) }
addFactory { SetReadStatus(get(), get(), get(), get()) }
addFactory { ShouldUpdateDbChapter() }
addFactory { SyncChaptersWithSource(get(), get(), get(), get()) }
addFactory { SyncChaptersWithTrackServiceTwoWay(get(), get()) }
addSingletonFactory<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) }
addFactory { DeleteAllAnimeHistory(get()) }
addFactory { GetAnimeHistory(get()) }
@ -257,8 +264,6 @@ class DomainModule : InjektModule {
addFactory { GetRemoteAnime(get()) }
addFactory { GetAnimeSourcesWithFavoriteCount(get(), get()) }
addFactory { GetAnimeSourcesWithNonLibraryAnime(get()) }
addFactory { SetAnimeMigrateSorting(get()) }
addFactory { ToggleAnimeLanguage(get()) }
addFactory { ToggleAnimeSource(get()) }
addFactory { ToggleAnimeSourcePin(get()) }

View file

@ -23,7 +23,7 @@ class GetAnime(
return animeRepository.getAnimeByIdAsFlow(id)
}
suspend fun await(url: String, sourceId: Long): Anime? {
return animeRepository.getAnimeByUrlAndSourceId(url, sourceId)
fun subscribe(url: String, sourceId: Long): Flow<Anime?> {
return animeRepository.getAnimeByUrlAndSourceIdAsFlow(url, sourceId)
}
}

View file

@ -4,7 +4,7 @@ import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.repository.AnimeRepository
import kotlinx.coroutines.flow.Flow
class GetFavorites(
class GetAnimeFavorites(
private val animeRepository: AnimeRepository,
) {

View file

@ -24,4 +24,8 @@ class GetAnimeWithEpisodes(
suspend fun awaitAnime(id: Long): Anime {
return animeRepository.getAnimeById(id)
}
suspend fun awaitEpisodes(id: Long): List<Episode> {
return episodeRepository.getEpisodeByAnimeId(id)
}
}

View file

@ -1,7 +1,7 @@
package eu.kanade.domain.anime.interactor
import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.tachiyomi.data.database.models.AnimelibAnime
import eu.kanade.domain.animelib.model.AnimelibAnime
import kotlinx.coroutines.flow.Flow
class GetAnimelibAnime(

View file

@ -1,13 +0,0 @@
package eu.kanade.domain.anime.interactor
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.repository.AnimeRepository
class InsertAnime(
private val animeRepository: AnimeRepository,
) {
suspend fun await(anime: Anime): Long? {
return animeRepository.insert(anime)
}
}

View file

@ -0,0 +1,35 @@
package eu.kanade.domain.anime.interactor
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.repository.AnimeRepository
class NetworkToLocalAnime(
private val animeRepository: AnimeRepository,
) {
suspend fun await(anime: Anime, sourceId: Long): Anime {
val localAnime = getAnime(anime.url, sourceId)
return when {
localAnime == null -> {
val id = insertAnime(anime.copy(source = sourceId))
anime.copy(id = id!!)
}
!localAnime.favorite -> {
// if the anime isn't a favorite, set its display title from source
// if it later becomes a favorite, updated title will go to db
localAnime.copy(title = anime.title)
}
else -> {
localAnime
}
}
}
private suspend fun getAnime(url: String, sourceId: Long): Anime? {
return animeRepository.getAnimeByUrlAndSourceId(url, sourceId)
}
private suspend fun insertAnime(anime: Anime): Long? {
return animeRepository.insert(anime)
}
}

View file

@ -6,8 +6,8 @@ import eu.kanade.domain.anime.model.hasCustomCover
import eu.kanade.domain.anime.model.isLocal
import eu.kanade.domain.anime.model.toDbAnime
import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
import tachiyomi.animesource.model.AnimeInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.Date
@ -20,23 +20,30 @@ class UpdateAnime(
return animeRepository.update(animeUpdate)
}
suspend fun awaitAll(values: List<AnimeUpdate>): Boolean {
return animeRepository.updateAll(values)
suspend fun awaitAll(animeUpdates: List<AnimeUpdate>): Boolean {
return animeRepository.updateAll(animeUpdates)
}
suspend fun awaitUpdateFromSource(
localAnime: Anime,
remoteAnime: AnimeInfo,
remoteAnime: SAnime,
manualFetch: Boolean,
coverCache: AnimeCoverCache = Injekt.get(),
): Boolean {
// if the anime isn't a favorite, set its title from source and update in db
val title = if (!localAnime.favorite) remoteAnime.title else null
val remoteTitle = try {
remoteAnime.title
} catch (_: UninitializedPropertyAccessException) {
""
}
// Never refresh covers if the url is empty to avoid "losing" existing covers
val updateCover = remoteAnime.cover.isNotEmpty() && (manualFetch || localAnime.thumbnailUrl != remoteAnime.cover)
val coverLastModified = if (updateCover) {
// if the anime isn't a favorite, set its title from source and update in db
val title = if (remoteTitle.isEmpty() || localAnime.favorite) null else remoteTitle
val coverLastModified =
when {
// Never refresh covers if the url is empty to avoid "losing" existing covers
remoteAnime.thumbnail_url.isNullOrEmpty() -> null
!manualFetch && localAnime.thumbnailUrl == remoteAnime.thumbnail_url -> null
localAnime.isLocal() -> Date().time
localAnime.hasCustomCover(coverCache) -> {
coverCache.deleteFromCache(localAnime.toDbAnime(), false)
@ -47,19 +54,21 @@ class UpdateAnime(
Date().time
}
}
} else null
val thumbnailUrl = remoteAnime.thumbnail_url?.takeIf { it.isNotEmpty() }
return animeRepository.update(
AnimeUpdate(
id = localAnime.id,
title = title?.takeIf { it.isNotEmpty() },
title = title,
coverLastModified = coverLastModified,
author = remoteAnime.author,
artist = remoteAnime.artist,
description = remoteAnime.description,
genre = remoteAnime.genres,
thumbnailUrl = remoteAnime.cover.takeIf { it.isNotEmpty() },
genre = remoteAnime.getGenres(),
thumbnailUrl = thumbnailUrl,
status = remoteAnime.status.toLong(),
updateStrategy = remoteAnime.update_strategy,
initialized = true,
),
)

View file

@ -1,13 +1,13 @@
package eu.kanade.domain.anime.model
import eu.kanade.data.listOfStringsAdapter
import eu.kanade.domain.base.BasePreferences
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.data.cache.AnimeCoverCache
import eu.kanade.tachiyomi.data.database.models.AnimeImpl
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.widget.ExtendedNavigationView
import tachiyomi.animesource.model.AnimeInfo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.Serializable
@ -30,6 +30,7 @@ data class Anime(
val genre: List<String>?,
val status: Long,
val thumbnailUrl: String?,
val updateStrategy: UpdateStrategy,
val initialized: Boolean,
) : Serializable {
@ -79,7 +80,7 @@ data class Anime(
}
fun forceDownloaded(): Boolean {
return favorite && Injekt.get<PreferencesHelper>().downloadedOnly().get()
return favorite && Injekt.get<BasePreferences>().downloadedOnly().get()
}
fun sortDescending(): Boolean {
@ -98,6 +99,28 @@ data class Anime(
it.initialized = initialized
}
fun copyFrom(other: SAnime): Anime {
val author = other.author ?: author
val artist = other.artist ?: artist
val description = other.description ?: description
val genres = if (other.genre != null) {
other.getGenres()
} else {
genre
}
val thumbnailUrl = other.thumbnail_url ?: thumbnailUrl
return this.copy(
author = author,
artist = artist,
description = description,
genre = genres,
thumbnailUrl = thumbnailUrl,
status = other.status.toLong(),
updateStrategy = other.update_strategy,
initialized = other.initialized && initialized,
)
}
companion object {
// Generic filter that does not filter anything
const val SHOW_ALL = 0x00000000L
@ -133,17 +156,18 @@ data class Anime(
title = "",
source = -1L,
favorite = false,
lastUpdate = -1L,
dateAdded = -1L,
viewerFlags = -1L,
episodeFlags = -1L,
coverLastModified = -1L,
lastUpdate = 0L,
dateAdded = 0L,
viewerFlags = 0L,
episodeFlags = 0L,
coverLastModified = 0L,
artist = null,
author = null,
description = null,
genre = null,
status = 0L,
thumbnailUrl = null,
updateStrategy = UpdateStrategy.ALWAYS_UPDATE,
initialized = false,
)
}
@ -181,20 +205,10 @@ fun Anime.toDbAnime(): DbAnime = AnimeImpl().also {
it.genre = genre?.let(listOfStringsAdapter::encode)
it.status = status.toInt()
it.thumbnail_url = thumbnailUrl
it.update_strategy = updateStrategy
it.initialized = initialized
}
fun Anime.toAnimeInfo(): AnimeInfo = AnimeInfo(
artist = artist ?: "",
author = author ?: "",
cover = thumbnailUrl ?: "",
description = description ?: "",
genres = genre ?: emptyList(),
key = url,
status = status.toInt(),
title = title,
)
fun Anime.toAnimeUpdate(): AnimeUpdate {
return AnimeUpdate(
id = id,
@ -213,6 +227,22 @@ fun Anime.toAnimeUpdate(): AnimeUpdate {
genre = genre,
status = status,
thumbnailUrl = thumbnailUrl,
updateStrategy = updateStrategy,
initialized = initialized,
)
}
fun SAnime.toDomainAnime(): Anime {
return Anime.create().copy(
url = url,
title = title,
artist = artist,
author = author,
description = description,
genre = getGenres(),
status = status.toLong(),
thumbnailUrl = thumbnail_url,
updateStrategy = update_strategy,
initialized = initialized,
)
}

View file

@ -1,5 +1,7 @@
package eu.kanade.domain.anime.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
data class AnimeUpdate(
val id: Long,
val source: Long? = null,
@ -17,5 +19,6 @@ data class AnimeUpdate(
val genre: List<String>? = null,
val status: Long? = null,
val thumbnailUrl: String? = null,
val updateStrategy: UpdateStrategy? = null,
val initialized: Boolean? = null,
)

View file

@ -2,7 +2,7 @@ package eu.kanade.domain.anime.repository
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.model.AnimeUpdate
import eu.kanade.tachiyomi.data.database.models.AnimelibAnime
import eu.kanade.domain.animelib.model.AnimelibAnime
import kotlinx.coroutines.flow.Flow
interface AnimeRepository {
@ -13,6 +13,8 @@ interface AnimeRepository {
suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime?
fun getAnimeByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Anime?>
suspend fun getFavorites(): List<Anime>
suspend fun getAnimelibAnime(): List<AnimelibAnime>
@ -31,5 +33,5 @@ interface AnimeRepository {
suspend fun update(update: AnimeUpdate): Boolean
suspend fun updateAll(values: List<AnimeUpdate>): Boolean
suspend fun updateAll(animeUpdates: List<AnimeUpdate>): Boolean
}

View file

@ -2,19 +2,19 @@ package eu.kanade.domain.animedownload.interactor
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.toDbEpisode
import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.data.download.AnimeDownloadManager
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
class DeleteAnimeDownload(
private val sourceManager: AnimeSourceManager,
private val downloadManager: AnimeDownloadManager,
) {
suspend fun awaitAll(anime: Anime, vararg values: Episode) = withContext(NonCancellable) {
suspend fun awaitAll(anime: Anime, vararg episodes: Episode) = withNonCancellableContext {
sourceManager.get(anime.source)?.let { source ->
downloadManager.deleteEpisodes(values.toList(), anime, source)
downloadManager.deleteEpisodes(episodes.map { it.toDbEpisode() }, anime, source)
}
}
}

View file

@ -1,20 +1,19 @@
package eu.kanade.domain.animeextension.interactor
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
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: PreferencesHelper,
private val preferences: SourcePreferences,
private val extensionManager: AnimeExtensionManager,
) {
fun subscribe(): Flow<List<String>> {
return combine(
preferences.enabledLanguages().asFlow(),
extensionManager.getAvailableExtensionsObservable().asFlow(),
preferences.enabledLanguages().changes(),
extensionManager.availableExtensionsFlow,
) { enabledLanguage, availableExtensions ->
availableExtensions
.map { it.lang }

View file

@ -1,14 +1,14 @@
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 eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.model.AnimeExtension
import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetAnimeExtensionSources(
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun subscribe(extension: AnimeExtension.Installed): Flow<List<AnimeExtensionSourceItem>> {
@ -16,7 +16,7 @@ class GetAnimeExtensionSources(
val isMultiLangSingleSource =
isMultiSource && extension.sources.map { it.name }.distinct().size == 1
return preferences.disabledSources().asFlow().map { disabledSources ->
return preferences.disabledAnimeSources().changes().map { disabledSources ->
fun AnimeSource.isEnabled() = id.toString() !in disabledSources
extension.sources

View file

@ -1,25 +0,0 @@
package eu.kanade.domain.animeextension.interactor
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
import eu.kanade.tachiyomi.extension.model.AnimeExtension
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetAnimeExtensionUpdates(
private val preferences: PreferencesHelper,
private val extensionManager: AnimeExtensionManager,
) {
fun subscribe(): Flow<List<AnimeExtension.Installed>> {
val showNsfwSources = preferences.showNsfwSource().get()
return extensionManager.getInstalledExtensionsObservable().asFlow()
.map { installed ->
installed
.filter { it.hasUpdate && (showNsfwSources || it.isNsfw.not()) }
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
}

View file

@ -1,35 +1,33 @@
package eu.kanade.domain.animeextension.interactor
import eu.kanade.core.util.asFlow
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
import eu.kanade.tachiyomi.extension.model.AnimeExtension
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
typealias ExtensionSegregation = Triple<List<AnimeExtension.Installed>, List<AnimeExtension.Untrusted>, List<AnimeExtension.Available>>
class GetAnimeExtensions(
private val preferences: PreferencesHelper,
class GetAnimeExtensionsByType(
private val preferences: SourcePreferences,
private val extensionManager: AnimeExtensionManager,
) {
fun subscribe(): Flow<ExtensionSegregation> {
fun subscribe(): Flow<AnimeExtensions> {
val showNsfwSources = preferences.showNsfwSource().get()
return combine(
preferences.enabledLanguages().asFlow(),
extensionManager.getInstalledExtensionsObservable().asFlow(),
extensionManager.getUntrustedExtensionsObservable().asFlow(),
extensionManager.getAvailableExtensionsObservable().asFlow(),
preferences.enabledLanguages().changes(),
extensionManager.installedExtensionsFlow,
extensionManager.untrustedExtensionsFlow,
extensionManager.availableExtensionsFlow,
) { _activeLanguages, _installed, _untrusted, _available ->
val installed = _installed
.filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) }
val (updates, installed) = _installed
.filter { (showNsfwSources || it.isNsfw.not()) }
.sortedWith(
compareBy<AnimeExtension.Installed> { it.isObsolete.not() }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
)
.partition { it.hasUpdate }
val untrusted = _untrusted
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
@ -38,11 +36,11 @@ class GetAnimeExtensions(
.filter { extension ->
_installed.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } &&
extension.lang in _activeLanguages &&
(showNsfwSources || extension.isNsfw.not())
}
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
Triple(installed, untrusted, available)
AnimeExtensions(updates, installed, available, untrusted)
}
}
}

View file

@ -0,0 +1,10 @@
package eu.kanade.domain.animeextension.model
import eu.kanade.tachiyomi.animeextension.model.AnimeExtension
data class AnimeExtensions(
val updates: List<AnimeExtension.Installed>,
val installed: List<AnimeExtension.Installed>,
val available: List<AnimeExtension.Available>,
val untrusted: List<AnimeExtension.Untrusted>,
)

View file

@ -2,7 +2,7 @@ package eu.kanade.domain.animehistory.interactor
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
class DeleteAnimeHistoryTable(
class DeleteAllAnimeHistory(
private val repository: AnimeHistoryRepository,
) {

View file

@ -1,8 +1,5 @@
package eu.kanade.domain.animehistory.interactor
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
import kotlinx.coroutines.flow.Flow
@ -11,11 +8,7 @@ class GetAnimeHistory(
private val repository: AnimeHistoryRepository,
) {
fun subscribe(query: String): Flow<PagingData<AnimeHistoryWithRelations>> {
return Pager(
PagingConfig(pageSize = 25),
) {
repository.getHistory(query)
}.flow
fun subscribe(query: String): Flow<List<AnimeHistoryWithRelations>> {
return repository.getHistory(query)
}
}

View file

@ -1,18 +1,51 @@
package eu.kanade.domain.animehistory.interactor
import eu.kanade.domain.anime.interactor.GetAnime
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
import eu.kanade.domain.episode.interactor.GetEpisode
import eu.kanade.domain.episode.interactor.GetEpisodeByAnimeId
import eu.kanade.domain.episode.model.Episode
import eu.kanade.tachiyomi.util.episode.getEpisodeSort
class GetNextEpisode(
private val repository: AnimeHistoryRepository,
private val getEpisode: GetEpisode,
private val getEpisodeByAnimeId: GetEpisodeByAnimeId,
private val getAnime: GetAnime,
private val historyRepository: AnimeHistoryRepository,
) {
suspend fun await(animeId: Long, episodeId: Long): Episode? {
return repository.getNextEpisode(animeId, episodeId)
suspend fun await(): Episode? {
val history = historyRepository.getLastHistory() ?: return null
return await(history.animeId, history.episodeId)
}
suspend fun await(): Episode? {
val history = repository.getLastHistory() ?: return null
return repository.getNextEpisode(history.animeId, history.episodeId)
suspend fun await(animeId: Long, episodeId: Long): Episode? {
val episode = getEpisode.await(episodeId) ?: return null
val anime = getAnime.await(animeId) ?: return null
if (!episode.seen) return episode
val episodes = getEpisodeByAnimeId.await(animeId)
.sortedWith(getEpisodeSort(anime, sortDescending = false))
val currEpisodeIndex = episodes.indexOfFirst { episode.id == it.id }
return when (anime.sorting) {
Anime.EPISODE_SORTING_SOURCE -> episodes.getOrNull(currEpisodeIndex + 1)
Anime.EPISODE_SORTING_NUMBER -> {
val episodeNumber = episode.episodeNumber
((currEpisodeIndex + 1) until episodes.size)
.map { episodes[it] }
.firstOrNull {
it.episodeNumber > episodeNumber && it.episodeNumber <= episodeNumber + 1
}
}
Anime.EPISODE_SORTING_UPLOAD_DATE -> {
episodes.drop(currEpisodeIndex + 1)
.firstOrNull { it.dateUpload >= episode.dateUpload }
}
else -> throw NotImplementedError("Invalid episode sorting method: ${anime.sorting}")
}
}
}

View file

@ -1,18 +1,15 @@
package eu.kanade.domain.animehistory.repository
import androidx.paging.PagingSource
import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate
import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations
import eu.kanade.domain.episode.model.Episode
import kotlinx.coroutines.flow.Flow
interface AnimeHistoryRepository {
fun getHistory(query: String): PagingSource<Long, AnimeHistoryWithRelations>
fun getHistory(query: String): Flow<List<AnimeHistoryWithRelations>>
suspend fun getLastHistory(): AnimeHistoryWithRelations?
suspend fun getNextEpisode(animeId: Long, episodeId: Long): Episode?
suspend fun resetHistory(historyId: Long)
suspend fun resetHistoryByAnimeId(animeId: Long)

View file

@ -0,0 +1,24 @@
package eu.kanade.domain.animelib.model
import eu.kanade.domain.anime.model.Anime
data class AnimelibAnime(
val anime: Anime,
val category: Long,
val totalEpisodes: Long,
val seenCount: Long,
val bookmarkCount: Long,
val latestUpload: Long,
val episodeFetchedAt: Long,
val lastSeen: Long,
) {
val id: Long = anime.id
val unseenCount
get() = totalEpisodes - seenCount
val hasBookmarks
get() = bookmarkCount > 0
val hasStarted = seenCount > 0
}

View file

@ -1,20 +0,0 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSourceData
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class GetAnimeSourceData(
private val repository: AnimeSourceRepository,
) {
suspend fun await(id: Long): AnimeSourceData? {
return try {
repository.getAnimeSourceData(id)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
null
}
}
}

View file

@ -3,7 +3,7 @@ package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.domain.source.service.SourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import java.text.Collator
@ -12,13 +12,13 @@ import java.util.Locale
class GetAnimeSourcesWithFavoriteCount(
private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun subscribe(): Flow<List<Pair<AnimeSource, Long>>> {
return combine(
preferences.migrationSortingDirection().asFlow(),
preferences.migrationSortingMode().asFlow(),
preferences.migrationSortingDirection().changes(),
preferences.migrationSortingMode().changes(),
repository.getSourcesWithFavoriteCount(),
) { direction, mode, list ->
list.sortedWith(sortFn(direction, mode))

View file

@ -1,14 +1,14 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSourceWithCount
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.animesource.AnimeSource
import kotlinx.coroutines.flow.Flow
class GetAnimeSourcesWithNonLibraryAnime(
private val repository: AnimeSourceRepository,
) {
fun subscribe(): Flow<List<Pair<AnimeSource, Long>>> {
fun subscribe(): Flow<List<AnimeSourceWithCount>> {
return repository.getSourcesWithNonLibraryAnime()
}
}

View file

@ -4,23 +4,23 @@ import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.model.Pin
import eu.kanade.domain.animesource.model.Pins
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
class GetEnabledAnimeSources(
private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun subscribe(): Flow<List<AnimeSource>> {
return combine(
preferences.pinnedAnimeSources().asFlow(),
preferences.enabledLanguages().asFlow(),
preferences.disabledAnimeSources().asFlow(),
preferences.lastUsedAnimeSource().asFlow(),
preferences.pinnedAnimeSources().changes(),
preferences.enabledLanguages().changes(),
preferences.disabledAnimeSources().changes(),
preferences.lastUsedAnimeSource().changes(),
repository.getSources(),
) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, sources ->
val duplicatePins = preferences.duplicatePinnedSources().get()

View file

@ -2,20 +2,20 @@ package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
class GetLanguagesWithAnimeSources(
private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun subscribe(): Flow<Map<String, List<AnimeSource>>> {
return combine(
preferences.enabledLanguages().asFlow(),
preferences.disabledSources().asFlow(),
preferences.enabledLanguages().changes(),
preferences.disabledSources().changes(),
repository.getOnlineSources(),
) { enabledLanguage, disabledSource, onlineSources ->
val sortedSources = onlineSources.sortedWith(

View file

@ -0,0 +1,23 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
class GetRemoteAnime(
private val repository: AnimeSourceRepository,
) {
fun subscribe(sourceId: Long, query: String, filterList: AnimeFilterList): AnimeSourcePagingSourceType {
return when (query) {
QUERY_POPULAR -> repository.getPopular(sourceId)
QUERY_LATEST -> repository.getLatest(sourceId)
else -> repository.search(sourceId, query, filterList)
}
}
companion object {
const val QUERY_POPULAR = "eu.kanade.domain.animesource.interactor.POPULAR"
const val QUERY_LATEST = "eu.kanade.domain.animesource.interactor.LATEST"
}
}

View file

@ -1,23 +1,24 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.core.preference.getAndSet
class ToggleAnimeSource(
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun await(source: AnimeSource, enable: Boolean = source.id.toString() in preferences.disabledAnimeSources().get()) {
fun await(source: AnimeSource, enable: Boolean = isEnabled(source.id)) {
await(source.id, enable)
}
fun await(sourceId: Long, enable: Boolean = sourceId.toString() in preferences.disabledAnimeSources().get()) {
if (enable) {
preferences.disabledAnimeSources() -= sourceId.toString()
} else {
preferences.disabledAnimeSources() += sourceId.toString()
fun await(sourceId: Long, enable: Boolean = isEnabled(sourceId)) {
preferences.disabledAnimeSources().getAndSet { disabled ->
if (enable) disabled.minus("$sourceId") else disabled.plus("$sourceId")
}
}
private fun isEnabled(sourceId: Long): Boolean {
return sourceId.toString() in preferences.disabledAnimeSources().get()
}
}

View file

@ -1,20 +1,17 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.core.preference.getAndSet
class ToggleAnimeSourcePin(
private val preferences: PreferencesHelper,
private val preferences: SourcePreferences,
) {
fun await(source: AnimeSource) {
val isPinned = source.id.toString() in preferences.pinnedAnimeSources().get()
if (isPinned) {
preferences.pinnedAnimeSources() -= source.id.toString()
} else {
preferences.pinnedAnimeSources() += source.id.toString()
preferences.pinnedAnimeSources().getAndSet { pinned ->
if (isPinned) pinned.minus("${source.id}") else pinned.plus("${source.id}")
}
}
}

View file

@ -1,19 +0,0 @@
package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSourceData
import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class UpsertAnimeSourceData(
private val repository: AnimeSourceRepository,
) {
suspend fun await(sourceData: AnimeSourceData) {
try {
repository.upsertAnimeSourceData(sourceData.id, sourceData.lang, sourceData.name)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
}
}

View file

@ -4,4 +4,7 @@ data class AnimeSourceData(
val id: Long,
val lang: String,
val name: String,
)
) {
val isMissingInfo: Boolean = name.isBlank() || lang.isBlank()
}

View file

@ -0,0 +1,6 @@
package eu.kanade.domain.animesource.model
import androidx.paging.PagingSource
import eu.kanade.tachiyomi.animesource.model.SAnime
typealias AnimeSourcePagingSourceType = PagingSource<Long, SAnime>

View file

@ -0,0 +1,13 @@
package eu.kanade.domain.animesource.model
data class AnimeSourceWithCount(
val source: AnimeSource,
val count: Long,
) {
val id: Long
get() = source.id
val name: String
get() = source.name
}

View file

@ -3,7 +3,7 @@ package eu.kanade.domain.animesource.model
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -17,8 +17,11 @@ data class AnimeSource(
val isUsedLast: Boolean = false,
) {
val nameWithLanguage: String
get() = "$name (${lang.uppercase()})"
val visualName: String
get() = when {
lang.isEmpty() -> name
else -> "$name (${lang.uppercase()})"
}
val icon: ImageBitmap?
get() {

View file

@ -0,0 +1,12 @@
package eu.kanade.domain.animesource.repository
import eu.kanade.domain.animesource.model.AnimeSourceData
import kotlinx.coroutines.flow.Flow
interface AnimeSourceDataRepository {
fun subscribeAll(): Flow<List<AnimeSourceData>>
suspend fun getSourceData(id: Long): AnimeSourceData?
suspend fun upsertSourceData(id: Long, lang: String, name: String)
}

View file

@ -1,9 +1,10 @@
package eu.kanade.domain.animesource.repository
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.model.AnimeSourceData
import eu.kanade.domain.animesource.model.AnimeSourcePagingSourceType
import eu.kanade.domain.animesource.model.AnimeSourceWithCount
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import kotlinx.coroutines.flow.Flow
import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource
interface AnimeSourceRepository {
@ -13,9 +14,11 @@ interface AnimeSourceRepository {
fun getSourcesWithFavoriteCount(): Flow<List<Pair<AnimeSource, Long>>>
fun getSourcesWithNonLibraryAnime(): Flow<List<Pair<LoadedAnimeSource, Long>>>
fun getSourcesWithNonLibraryAnime(): Flow<List<AnimeSourceWithCount>>
suspend fun getAnimeSourceData(id: Long): AnimeSourceData?
fun search(sourceId: Long, query: String, filterList: AnimeFilterList): AnimeSourcePagingSourceType
suspend fun upsertAnimeSourceData(id: Long, lang: String, name: String)
fun getPopular(sourceId: Long): AnimeSourcePagingSourceType
fun getLatest(sourceId: Long): AnimeSourcePagingSourceType
}

View file

@ -19,10 +19,6 @@ class GetAnimeTracks(
}
}
fun subscribe(): Flow<List<AnimeTrack>> {
return animetrackRepository.getAnimeTracksAsFlow()
}
fun subscribe(animeId: Long): Flow<List<AnimeTrack>> {
return animetrackRepository.getAnimeTracksByAnimeIdAsFlow(animeId)
}

View file

@ -0,0 +1,20 @@
package eu.kanade.domain.animetrack.interactor
import eu.kanade.domain.animetrack.repository.AnimeTrackRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class GetTracksPerAnime(
private val trackRepository: AnimeTrackRepository,
) {
fun subscribe(): Flow<Map<Long, List<Long>>> {
return trackRepository.getAnimeTracksAsFlow().map { tracks ->
tracks
.groupBy { it.animeId }
.mapValues { entry ->
entry.value.map { it.syncId }
}
}
}
}

View file

@ -0,0 +1,24 @@
package eu.kanade.domain.animeupdates.interactor
import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations
import eu.kanade.domain.animeupdates.repository.AnimeUpdatesRepository
import eu.kanade.domain.library.service.LibraryPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
import java.util.Calendar
class GetAnimeUpdates(
private val repository: AnimeUpdatesRepository,
private val preferences: LibraryPreferences,
) {
fun subscribe(calendar: Calendar): Flow<List<AnimeUpdatesWithRelations>> = subscribe(calendar.time.time)
fun subscribe(after: Long): Flow<List<AnimeUpdatesWithRelations>> {
return repository.subscribeAll(after)
.onEach { updates ->
// Set unread chapter count for bottom bar badge
preferences.unseenUpdatesCount().set(updates.count { !it.seen })
}
}
}

View file

@ -0,0 +1,16 @@
package eu.kanade.domain.animeupdates.model
import eu.kanade.domain.manga.model.MangaCover
data class AnimeUpdatesWithRelations(
val animeId: Long,
val animeTitle: String,
val episodeId: Long,
val episodeName: String,
val scanlator: String?,
val seen: Boolean,
val bookmark: Boolean,
val sourceId: Long,
val dateFetch: Long,
val coverData: MangaCover,
)

View file

@ -0,0 +1,9 @@
package eu.kanade.domain.animeupdates.repository
import eu.kanade.domain.animeupdates.model.AnimeUpdatesWithRelations
import kotlinx.coroutines.flow.Flow
interface AnimeUpdatesRepository {
fun subscribeAll(after: Long): Flow<List<AnimeUpdatesWithRelations>>
}

View file

@ -2,6 +2,10 @@ package eu.kanade.domain.backup.service
import eu.kanade.tachiyomi.core.preference.PreferenceStore
import eu.kanade.tachiyomi.core.provider.FolderProvider
import eu.kanade.tachiyomi.data.preference.FLAG_CATEGORIES
import eu.kanade.tachiyomi.data.preference.FLAG_CHAPTERS
import eu.kanade.tachiyomi.data.preference.FLAG_HISTORY
import eu.kanade.tachiyomi.data.preference.FLAG_TRACK
class BackupPreferences(
private val folderProvider: FolderProvider,
@ -13,4 +17,6 @@ class BackupPreferences(
fun numberOfBackups() = preferenceStore.getInt("backup_slots", 2)
fun backupInterval() = preferenceStore.getInt("backup_interval", 12)
fun backupFlags() = preferenceStore.getStringSet("backup_flags", setOf(FLAG_CATEGORIES, FLAG_CHAPTERS, FLAG_HISTORY, FLAG_TRACK))
}

View file

@ -5,8 +5,6 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
import eu.kanade.tachiyomi.core.preference.getEnum
import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
class BasePreferences(
val context: Context,
@ -26,5 +24,6 @@ class BasePreferences(
if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER,
)
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", isPreviewBuildType || isReleaseBuildType)
// acra is disabled
fun acraEnabled() = preferenceStore.getBoolean("acra.enable", false)
}

View file

@ -0,0 +1,52 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.anyWithName
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.library.service.LibraryPreferences
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class CreateAnimeCategoryWithName(
private val categoryRepository: CategoryRepositoryAnime,
private val preferences: LibraryPreferences,
) {
private val initialFlags: Long
get() {
val sort = preferences.librarySortingMode().get()
return preferences.libraryDisplayMode().get().flag or
sort.type.flag or
sort.direction.flag
}
suspend fun await(name: String): Result = withNonCancellableContext {
val categories = categoryRepository.getAll()
if (categories.anyWithName(name)) {
return@withNonCancellableContext Result.NameAlreadyExistsError
}
val nextOrder = categories.maxOfOrNull { it.order }?.plus(1) ?: 0
val newCategory = Category(
id = 0,
name = name,
order = nextOrder,
flags = initialFlags,
)
try {
categoryRepository.insert(newCategory)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
sealed class Result {
object Success : Result()
object NameAlreadyExistsError : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -0,0 +1,42 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class DeleteAnimeCategory(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long) = withNonCancellableContext {
try {
categoryRepository.delete(categoryId)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@withNonCancellableContext Result.InternalError(e)
}
val categories = categoryRepository.getAll()
val updates = categories.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartial(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
sealed class Result {
object Success : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -1,12 +0,0 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
class DeleteCategoryAnime(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long) {
categoryRepository.delete(categoryId)
}
}

View file

@ -4,7 +4,7 @@ import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import kotlinx.coroutines.flow.Flow
class GetCategoriesAnime(
class GetAnimeCategories(
private val categoryRepository: CategoryRepositoryAnime,
) {

View file

@ -1,22 +0,0 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
class InsertCategoryAnime(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(name: String, order: Long): Result {
return try {
categoryRepository.insert(name, order)
Result.Success
} catch (e: Exception) {
Result.Error(e)
}
}
sealed class Result {
object Success : Result()
data class Error(val error: Exception) : Result()
}
}

View file

@ -0,0 +1,42 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.model.anyWithName
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class RenameAnimeCategory(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long, name: String) = withNonCancellableContext {
val categories = categoryRepository.getAll()
if (categories.anyWithName(name)) {
return@withNonCancellableContext Result.NameAlreadyExistsError
}
val update = CategoryUpdate(
id = categoryId,
name = name,
)
try {
categoryRepository.updatePartial(update)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
suspend fun await(category: Category, name: String) = await(category.id, name)
sealed class Result {
object Success : Result()
object NameAlreadyExistsError : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -0,0 +1,50 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import logcat.LogPriority
class ReorderAnimeCategory(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long, newPosition: Int) = withNonCancellableContext {
val categories = categoryRepository.getAll().filterNot(Category::isSystemCategory)
val currentIndex = categories.indexOfFirst { it.id == categoryId }
if (currentIndex == newPosition) {
return@withNonCancellableContext Result.Unchanged
}
val reorderedCategories = categories.toMutableList()
val reorderedCategory = reorderedCategories.removeAt(currentIndex)
reorderedCategories.add(newPosition, reorderedCategory)
val updates = reorderedCategories.mapIndexed { index, category ->
CategoryUpdate(
id = category.id,
order = index.toLong(),
)
}
try {
categoryRepository.updatePartial(updates)
Result.Success
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
Result.InternalError(e)
}
}
suspend fun await(category: Category, newPosition: Long): Result =
await(category.id, newPosition.toInt())
sealed class Result {
object Success : Result()
object Unchanged : Result()
data class InternalError(val error: Throwable) : Result()
}
}

View file

@ -0,0 +1,17 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.library.model.plus
import eu.kanade.domain.library.service.LibraryPreferences
class ResetAnimeCategoryFlags(
private val preferences: LibraryPreferences,
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await() {
val display = preferences.libraryDisplayMode().get()
val sort = preferences.librarySortingMode().get()
categoryRepository.updateAllFlags(display + sort.type + sort.direction)
}
}

View file

@ -0,0 +1,34 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.library.model.LibraryDisplayMode
import eu.kanade.domain.library.model.plus
import eu.kanade.domain.library.service.LibraryPreferences
class SetDisplayModeForAnimeCategory(
private val preferences: LibraryPreferences,
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long, display: LibraryDisplayMode) {
val category = categoryRepository.get(categoryId) ?: return
val flags = category.flags + display
if (preferences.categorizedDisplaySettings().get()) {
categoryRepository.updatePartial(
CategoryUpdate(
id = category.id,
flags = flags,
),
)
} else {
preferences.libraryDisplayMode().set(display)
categoryRepository.updateAllFlags(flags)
}
}
suspend fun await(category: Category, display: LibraryDisplayMode) {
await(category.id, display)
}
}

View file

@ -0,0 +1,34 @@
package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.Category
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.library.model.LibrarySort
import eu.kanade.domain.library.model.plus
import eu.kanade.domain.library.service.LibraryPreferences
class SetSortModeForAnimeCategory(
private val preferences: LibraryPreferences,
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(categoryId: Long, type: LibrarySort.Type, direction: LibrarySort.Direction) {
val category = categoryRepository.get(categoryId) ?: return
val flags = category.flags + type + direction
if (preferences.categorizedDisplaySettings().get()) {
categoryRepository.updatePartial(
CategoryUpdate(
id = category.id,
flags = flags,
),
)
} else {
preferences.librarySortingMode().set(LibrarySort(type, direction))
categoryRepository.updateAllFlags(flags)
}
}
suspend fun await(category: Category, type: LibrarySort.Type, direction: LibrarySort.Direction) {
await(category.id, type, direction)
}
}

View file

@ -2,14 +2,15 @@ package eu.kanade.domain.category.interactor
import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
class UpdateCategoryAnime(
class UpdateAnimeCategory(
private val categoryRepository: CategoryRepositoryAnime,
) {
suspend fun await(payload: CategoryUpdate): Result {
return try {
categoryRepository.update(payload)
suspend fun await(payload: CategoryUpdate): Result = withNonCancellableContext {
try {
categoryRepository.updatePartial(payload)
Result.Success
} catch (e: Exception) {
Result.Error(e)

View file

@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.Flow
interface CategoryRepositoryAnime {
suspend fun get(id: Long): Category?
suspend fun getAll(): List<Category>
fun getAllAsFlow(): Flow<List<Category>>
@ -14,13 +16,13 @@ interface CategoryRepositoryAnime {
fun getCategoriesByAnimeIdAsFlow(animeId: Long): Flow<List<Category>>
@Throws(DuplicateNameException::class)
suspend fun insert(name: String, order: Long)
suspend fun insert(category: Category)
@Throws(DuplicateNameException::class)
suspend fun update(payload: CategoryUpdate)
suspend fun updatePartial(update: CategoryUpdate)
suspend fun updatePartial(updates: List<CategoryUpdate>)
suspend fun updateAllFlags(flags: Long?)
suspend fun delete(categoryId: Long)
suspend fun checkDuplicateName(name: String): Boolean
}

View file

@ -12,12 +12,18 @@ class DownloadPreferences(
fun downloadOnlyOverWifi() = preferenceStore.getBoolean("pref_download_only_over_wifi_key", true)
fun useExternalDownloader() = preferenceStore.getBoolean("use_external_downloader", false)
fun externalDownloaderSelection() = preferenceStore.getString("external_downloader_selection", "")
fun saveChaptersAsCBZ() = preferenceStore.getBoolean("save_chapter_as_cbz", true)
fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", false)
fun autoDownloadWhileReading() = preferenceStore.getInt("auto_download_while_reading", 0)
fun autoDownloadWhileWatching() = preferenceStore.getInt("auto_download_while_watching", 0)
fun removeAfterReadSlots() = preferenceStore.getInt("remove_after_read_slots", -1)
fun removeAfterMarkedAsRead() = preferenceStore.getBoolean("pref_remove_after_marked_as_read_key", false)
@ -25,10 +31,14 @@ class DownloadPreferences(
fun removeBookmarkedChapters() = preferenceStore.getBoolean("pref_remove_bookmarked", false)
fun removeExcludeCategories() = preferenceStore.getStringSet("remove_exclude_categories", emptySet())
fun removeExcludeAnimeCategories() = preferenceStore.getStringSet("remove_exclude_anime_categories", emptySet())
fun downloadNewChapters() = preferenceStore.getBoolean("download_new", false)
fun downloadNewEpisodes() = preferenceStore.getBoolean("download_new_episode", false)
fun downloadNewChapterCategories() = preferenceStore.getStringSet("download_new_categories", emptySet())
fun downloadNewEpisodeCategories() = preferenceStore.getStringSet("download_new_anime_categories", emptySet())
fun downloadNewChapterCategoriesExclude() = preferenceStore.getStringSet("download_new_categories_exclude", emptySet())
fun downloadNewEpisodeCategoriesExclude() = preferenceStore.getStringSet("download_new_anime_categories_exclude", emptySet())
}

View file

@ -0,0 +1,36 @@
package eu.kanade.domain.episode.interactor
import eu.kanade.domain.anime.interactor.GetAnimeFavorites
import eu.kanade.domain.anime.interactor.SetAnimeEpisodeFlags
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.library.service.LibraryPreferences
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
class SetAnimeDefaultEpisodeFlags(
private val libraryPreferences: LibraryPreferences,
private val setAnimeEpisodeFlags: SetAnimeEpisodeFlags,
private val getFavorites: GetAnimeFavorites,
) {
suspend fun await(anime: Anime) {
withNonCancellableContext {
with(libraryPreferences) {
setAnimeEpisodeFlags.awaitSetAllFlags(
animeId = anime.id,
unseenFilter = filterEpisodeBySeen().get(),
downloadedFilter = filterEpisodeByDownloaded().get(),
bookmarkedFilter = filterEpisodeByBookmarked().get(),
sortingMode = sortEpisodeBySourceOrNumber().get(),
sortingDirection = sortEpisodeByAscendingOrDescending().get(),
displayMode = displayEpisodeByNameOrNumber().get(),
)
}
}
}
suspend fun awaitAll() {
withNonCancellableContext {
getFavorites.await().forEach { await(it) }
}
}
}

View file

@ -3,17 +3,16 @@ package eu.kanade.domain.episode.interactor
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload
import eu.kanade.domain.download.service.DownloadPreferences
import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.EpisodeUpdate
import eu.kanade.domain.episode.repository.EpisodeRepository
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority
class SetSeenStatus(
private val preferences: PreferencesHelper,
private val downloadPreferences: DownloadPreferences,
private val deleteDownload: DeleteAnimeDownload,
private val animeRepository: AnimeRepository,
private val episodeRepository: EpisodeRepository,
@ -27,38 +26,33 @@ class SetSeenStatus(
)
}
suspend fun await(seen: Boolean, vararg values: Episode): Result = withContext(NonCancellable) f@{
val episodes = values.filterNot { it.seen == seen }
if (episodes.isEmpty()) {
return@f Result.NoEpisodes
suspend fun await(seen: Boolean, vararg episodes: Episode): Result = withNonCancellableContext {
val episodesToUpdate = episodes.filter {
when (seen) {
true -> !it.seen
false -> it.seen || it.lastSecondSeen > 0
}
val anime = episodes.fold(mutableSetOf<Anime>()) { acc, episode ->
if (acc.all { it.id != episode.animeId }) {
acc += animeRepository.getAnimeById(episode.animeId)
}
acc
if (episodesToUpdate.isEmpty()) {
return@withNonCancellableContext Result.NoEpisodes
}
try {
episodeRepository.updateAll(
episodes.map { episode ->
mapper(episode, seen)
},
episodesToUpdate.map { mapper(it, seen) },
)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
return@f Result.InternalError(e)
return@withNonCancellableContext Result.InternalError(e)
}
if (seen && preferences.removeAfterMarkedAsRead()) {
anime.forEach { anime ->
if (seen && downloadPreferences.removeAfterMarkedAsRead().get()) {
episodesToUpdate
.groupBy { it.animeId }
.forEach { (animeId, episodes) ->
deleteDownload.awaitAll(
anime = anime,
values = episodes
.filter { anime.id == it.animeId }
.toTypedArray(),
anime = animeRepository.getAnimeById(animeId),
episodes = episodes.toTypedArray(),
)
}
}
@ -66,10 +60,10 @@ class SetSeenStatus(
Result.Success
}
suspend fun await(animeId: Long, seen: Boolean): Result = withContext(NonCancellable) f@{
return@f await(
suspend fun await(animeId: Long, seen: Boolean): Result = withNonCancellableContext {
await(
seen = seen,
values = episodeRepository
episodes = episodeRepository
.getEpisodeByAnimeId(animeId)
.toTypedArray(),
)

View file

@ -1,16 +1,19 @@
package eu.kanade.domain.episode.interactor
import eu.kanade.data.episode.CleanupEpisodeName
import eu.kanade.data.episode.NoEpisodesException
import eu.kanade.domain.anime.interactor.UpdateAnime
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.toDbEpisode
import eu.kanade.domain.episode.model.toEpisodeUpdate
import eu.kanade.domain.episode.repository.EpisodeRepository
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.animesource.isLocal
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.data.download.AnimeDownloadManager
import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager
import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadProvider
import eu.kanade.tachiyomi.util.episode.EpisodeRecognition
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -20,6 +23,7 @@ import java.util.TreeSet
class SyncEpisodesWithSource(
private val downloadManager: AnimeDownloadManager = Injekt.get(),
private val downloadProvider: AnimeDownloadProvider = Injekt.get(),
private val episodeRepository: EpisodeRepository = Injekt.get(),
private val shouldUpdateDbEpisode: ShouldUpdateDbEpisode = Injekt.get(),
private val updateAnime: UpdateAnime = Injekt.get(),
@ -27,12 +31,20 @@ class SyncEpisodesWithSource(
private val getEpisodeByAnimeId: GetEpisodeByAnimeId = Injekt.get(),
) {
/**
* Method to synchronize db episodes with source ones
*
* @param rawSourceEpisodes the episodes from the source.
* @param anime the anime the episodes belong to.
* @param source the source the anime belongs to.
* @return Newly added episodes
*/
suspend fun await(
rawSourceEpisodes: List<SEpisode>,
anime: Anime,
source: AnimeSource,
): Pair<List<Episode>, List<Episode>> {
if (rawSourceEpisodes.isEmpty() && source.id != LocalAnimeSource.ID) {
): List<Episode> {
if (rawSourceEpisodes.isEmpty() && !source.isLocal()) {
throw NoEpisodesException()
}
@ -41,6 +53,7 @@ class SyncEpisodesWithSource(
.mapIndexed { i, sEpisode ->
Episode.create()
.copyFromSEpisode(sEpisode)
.copy(name = CleanupEpisodeName.await(sEpisode.name, anime.title))
.copy(animeId = anime.id, sourceOrder = i.toLong())
}
@ -94,8 +107,11 @@ class SyncEpisodesWithSource(
toAdd.add(toAddEpisode)
} else {
if (shouldUpdateDbEpisode.await(dbEpisode, episode)) {
if (dbEpisode.name != episode.name && downloadManager.isEpisodeDownloaded(dbEpisode.name, dbEpisode.scanlator, anime.title, anime.source)) {
downloadManager.renameEpisode(source, anime, dbEpisode, episode)
val shouldRenameEpisode = downloadProvider.isEpisodeDirNameChanged(dbEpisode, episode) &&
downloadManager.isEpisodeDownloaded(dbEpisode.name, dbEpisode.scanlator, anime.title, anime.source)
if (shouldRenameEpisode) {
downloadManager.renameEpisode(source, anime, dbEpisode.toDbEpisode(), episode.toDbEpisode())
}
var toChangeEpisode = dbEpisode.copy(
name = episode.name,
@ -113,18 +129,18 @@ class SyncEpisodesWithSource(
// Return if there's nothing to add, delete or change, avoiding unnecessary db transactions.
if (toAdd.isEmpty() && toDelete.isEmpty() && toChange.isEmpty()) {
return Pair(emptyList(), emptyList())
return emptyList()
}
val reAdded = mutableListOf<Episode>()
val deletedEpisodeNumbers = TreeSet<Float>()
val deletedSeenEpisodeNumbers = TreeSet<Float>()
val deletedBookmarkedEpisodeNumbers = TreeSet<Float>()
toDelete.forEach { episode ->
if (episode.seen) {
deletedSeenEpisodeNumbers.add(episode.episodeNumber)
}
if (episode.seen) deletedSeenEpisodeNumbers.add(episode.episodeNumber)
if (episode.bookmark) deletedBookmarkedEpisodeNumbers.add(episode.episodeNumber)
deletedEpisodeNumbers.add(episode.episodeNumber)
}
@ -133,20 +149,19 @@ class SyncEpisodesWithSource(
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the episodes from most to less recent, which is common.
var itemCount = toAdd.size
var updatedToAdd = toAdd.map { toAddItem ->
var episode = toAddItem.copy(dateFetch = rightNow + itemCount--)
if (episode.isRecognizedNumber.not() && episode.episodeNumber !in deletedEpisodeNumbers) return@map episode
if (episode.isRecognizedNumber.not() || episode.episodeNumber !in deletedEpisodeNumbers) return@map episode
if (episode.episodeNumber in deletedSeenEpisodeNumbers) {
episode = episode.copy(seen = true)
}
episode = episode.copy(
seen = episode.episodeNumber in deletedSeenEpisodeNumbers,
bookmark = episode.episodeNumber in deletedBookmarkedEpisodeNumbers,
)
// Try to to use the fetch date of the original entry to not pollute 'Updates' tab
val oldDateFetch = deletedEpisodeNumberDateFetchMap[episode.episodeNumber]
oldDateFetch?.let {
deletedEpisodeNumberDateFetchMap[episode.episodeNumber]?.let {
episode = episode.copy(dateFetch = it)
}
@ -173,7 +188,8 @@ class SyncEpisodesWithSource(
// Note that last_update actually represents last time the episode list changed at all
updateAnime.awaitUpdateLastUpdate(anime.id)
@Suppress("ConvertArgumentToSet") // See tachiyomiorg/tachiyomi#6372.
return Pair(updatedToAdd.subtract(reAdded).toList(), toDelete.subtract(reAdded).toList())
val reAddedUrls = reAdded.map { it.url }.toHashSet()
return updatedToAdd.filterNot { it.url in reAddedUrls }
}
}

View file

@ -38,7 +38,7 @@ data class Episode(
url = sEpisode.url,
dateUpload = sEpisode.date_upload,
episodeNumber = sEpisode.episode_number,
scanlator = sEpisode.scanlator,
scanlator = sEpisode.scanlator?.ifBlank { null },
)
}

View file

@ -16,6 +16,8 @@ interface EpisodeRepository {
suspend fun getEpisodeByAnimeId(animeId: Long): List<Episode>
suspend fun getBookmarkedEpisodesByAnimeId(animeId: Long): List<Episode>
suspend fun getEpisodeById(id: Long): Episode?
fun getEpisodeByAnimeIdAsFlow(animeId: Long): Flow<List<Episode>>

View file

@ -1,5 +1,6 @@
package eu.kanade.domain.library.service
import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.library.model.LibraryDisplayMode
import eu.kanade.domain.library.model.LibrarySort
import eu.kanade.domain.manga.model.Manga
@ -13,6 +14,7 @@ import eu.kanade.tachiyomi.widget.ExtendedNavigationView
class LibraryPreferences(
private val preferenceStore: PreferenceStore,
) {
fun bottomNavStyle() = preferenceStore.getInt("bottom_nav_style", 0)
fun libraryDisplayMode() = preferenceStore.getObject("pref_display_mode_library", LibraryDisplayMode.default, LibraryDisplayMode.Serializer::serialize, LibraryDisplayMode.Serializer::deserialize)
@ -60,41 +62,54 @@ class LibraryPreferences(
fun showUpdatesNavBadge() = preferenceStore.getBoolean("library_update_show_tab_badge", false)
fun unreadUpdatesCount() = preferenceStore.getInt("library_unread_updates_count", 0)
fun unseenUpdatesCount() = preferenceStore.getInt("library_unseen_updates_count", 0)
// endregion
// region Category
fun defaultCategory() = preferenceStore.getInt("default_category", -1)
fun defaultAnimeCategory() = preferenceStore.getInt("default_anime_category", -1)
fun lastUsedCategory() = preferenceStore.getInt("last_used_category", 0)
fun lastUsedAnimeCategory() = preferenceStore.getInt("last_used_anime_category", 0)
fun categoryTabs() = preferenceStore.getBoolean("display_category_tabs", true)
fun animeCategoryTabs() = preferenceStore.getBoolean("display_anime_category_tabs", true)
fun categoryNumberOfItems() = preferenceStore.getBoolean("display_number_of_items", false)
fun animeCategoryNumberOfItems() = preferenceStore.getBoolean("display_number_of_items_anime", false)
fun categorizedDisplaySettings() = preferenceStore.getBoolean("categorized_display", false)
fun libraryUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet())
fun animelibUpdateCategories() = preferenceStore.getStringSet("animelib_update_categories", emptySet())
fun libraryUpdateCategoriesExclude() = preferenceStore.getStringSet("library_update_categories_exclude", emptySet())
fun animelibUpdateCategoriesExclude() = preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet())
// endregion
// region Chapter
fun filterChapterByRead() = preferenceStore.getLong("default_chapter_filter_by_read", Manga.SHOW_ALL)
fun filterEpisodeBySeen() = preferenceStore.getLong("default_episode_filter_by_seen", Anime.SHOW_ALL)
fun filterChapterByDownloaded() = preferenceStore.getLong("default_chapter_filter_by_downloaded", Manga.SHOW_ALL)
fun filterEpisodeByDownloaded() = preferenceStore.getLong("default_episode_filter_by_downloaded", Anime.SHOW_ALL)
fun filterChapterByBookmarked() = preferenceStore.getLong("default_chapter_filter_by_bookmarked", Manga.SHOW_ALL)
fun filterEpisodeByBookmarked() = preferenceStore.getLong("default_episode_filter_by_bookmarked", Anime.SHOW_ALL)
// and upload date
fun sortChapterBySourceOrNumber() = preferenceStore.getLong("default_chapter_sort_by_source_or_number", Manga.CHAPTER_SORTING_SOURCE)
fun sortEpisodeBySourceOrNumber() = preferenceStore.getLong("default_episode_sort_by_source_or_number", Anime.EPISODE_SORTING_SOURCE)
fun displayChapterByNameOrNumber() = preferenceStore.getLong("default_chapter_display_by_name_or_number", Manga.CHAPTER_DISPLAY_NAME)
fun displayEpisodeByNameOrNumber() = preferenceStore.getLong("default_chapter_display_by_name_or_number", Anime.EPISODE_DISPLAY_NAME)
fun sortChapterByAscendingOrDescending() = preferenceStore.getLong("default_chapter_sort_by_ascending_or_descending", Manga.CHAPTER_SORT_DESC)
fun sortEpisodeByAscendingOrDescending() = preferenceStore.getLong("default_chapter_sort_by_ascending_or_descending", Anime.EPISODE_SORT_DESC)
fun setChapterSettingsDefault(manga: Manga) {
filterChapterByRead().set(manga.unreadFilterRaw)
@ -105,6 +120,15 @@ class LibraryPreferences(
sortChapterByAscendingOrDescending().set(if (manga.sortDescending()) Manga.CHAPTER_SORT_DESC else Manga.CHAPTER_SORT_ASC)
}
fun setEpisodeSettingsDefault(anime: Anime) {
filterEpisodeBySeen().set(anime.unseenFilterRaw)
filterEpisodeByDownloaded().set(anime.downloadedFilterRaw)
filterEpisodeByBookmarked().set(anime.bookmarkedFilterRaw)
sortEpisodeBySourceOrNumber().set(anime.sorting)
displayEpisodeByNameOrNumber().set(anime.displayMode)
sortEpisodeByAscendingOrDescending().set(if (anime.sortDescending()) Anime.EPISODE_SORT_DESC else Anime.EPISODE_SORT_ASC)
}
fun autoClearChapterCache() = preferenceStore.getBoolean("auto_clear_chapter_cache", false)
// endregion

View file

@ -16,12 +16,18 @@ class SourcePreferences(
fun disabledSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet())
fun disabledAnimeSources() = preferenceStore.getStringSet("hidden_anime_catalogues", emptySet())
fun pinnedSources() = preferenceStore.getStringSet("pinned_catalogues", emptySet())
fun pinnedAnimeSources() = preferenceStore.getStringSet("pinned_anime_catalogues", emptySet())
fun duplicatePinnedSources() = preferenceStore.getBoolean("duplicate_pinned_sources", false)
fun lastUsedSource() = preferenceStore.getLong("last_catalogue_source", -1)
fun lastUsedAnimeSource() = preferenceStore.getLong("last_anime_catalogue_source", -1)
fun showNsfwSource() = preferenceStore.getBoolean("show_nsfw_source", true)
fun migrationSortingMode() = preferenceStore.getEnum("pref_migration_sorting", SetMigrateSorting.Mode.ALPHABETICAL)
@ -30,7 +36,11 @@ class SourcePreferences(
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
fun animeextensionUpdatesCount() = preferenceStore.getInt("animeext_updates_count", 0)
fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet())
fun searchPinnedSourcesOnly() = preferenceStore.getBoolean("search_pinned_sources_only", false)
fun searchAnimePinnedSourcesOnly() = preferenceStore.getBoolean("search_pinned_anime_sources_only", false)
}

View file

@ -2,17 +2,12 @@ package eu.kanade.presentation.anime
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.rememberSplineBasedDecay
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
@ -24,7 +19,6 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -36,72 +30,54 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberTopAppBarScrollState
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import eu.kanade.domain.anime.model.Anime.Companion.EPISODE_DISPLAY_NUMBER
import eu.kanade.domain.episode.model.Episode
import eu.kanade.presentation.anime.components.AnimeBottomActionMenu
import eu.kanade.presentation.anime.components.AnimeActionRow
import eu.kanade.presentation.anime.components.AnimeEpisodeListItem
import eu.kanade.presentation.anime.components.AnimeInfoHeader
import eu.kanade.presentation.anime.components.AnimeSmallAppBar
import eu.kanade.presentation.anime.components.AnimeTopAppBar
import eu.kanade.presentation.anime.components.AnimeInfoBox
import eu.kanade.presentation.anime.components.EpisodeHeader
import eu.kanade.presentation.anime.components.ExpandableAnimeDescription
import eu.kanade.presentation.components.AnimeBottomActionMenu
import eu.kanade.presentation.components.EpisodeDownloadAction
import eu.kanade.presentation.components.ExtendedFloatingActionButton
import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.SwipeRefresh
import eu.kanade.presentation.components.TwoPanelBox
import eu.kanade.presentation.components.VerticalFastScroller
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.EpisodeDownloadAction
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior
import eu.kanade.presentation.manga.MangaScreenItem
import eu.kanade.presentation.manga.components.MangaToolbar
import eu.kanade.presentation.util.isScrolledToEnd
import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.animesource.getNameForAnimeInfo
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload
import eu.kanade.tachiyomi.ui.anime.AnimeScreenState
import eu.kanade.tachiyomi.ui.anime.EpisodeItem
import eu.kanade.tachiyomi.util.lang.toRelativeString
import kotlinx.coroutines.runBlocking
import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Date
import java.util.concurrent.TimeUnit
private val episodeDecimalFormat = DecimalFormat(
"#.###",
DecimalFormatSymbols()
.apply { decimalSeparator = '.' },
)
@Composable
fun AnimeScreen(
state: AnimeScreenState.Success,
snackbarHostState: SnackbarHostState,
windowWidthSizeClass: WindowWidthSizeClass,
isTabletUi: Boolean,
onBackClicked: () -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit,
onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?,
@ -130,8 +106,12 @@ fun AnimeScreen(
onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<Episode>) -> Unit,
// Episode selection
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
onAllEpisodeSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
if (!isTabletUi) {
AnimeScreenSmallImpl(
state = state,
snackbarHostState = snackbarHostState,
@ -142,9 +122,9 @@ fun AnimeScreen(
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onFilterClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueWatching,
onContinueWatching = onContinueWatching,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
@ -156,11 +136,13 @@ fun AnimeScreen(
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
onEpisodeSelected = onEpisodeSelected,
onAllEpisodeSelected = onAllEpisodeSelected,
onInvertSelection = onInvertSelection,
)
} else {
AnimeScreenLargeImpl(
state = state,
windowWidthSizeClass = windowWidthSizeClass,
snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked,
onEpisodeClicked = onEpisodeClicked,
@ -171,7 +153,7 @@ fun AnimeScreen(
onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh,
onContinueReading = onContinueWatching,
onContinueWatching = onContinueWatching,
onSearch = onSearch,
onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked,
@ -183,6 +165,9 @@ fun AnimeScreen(
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked,
onMultiDeleteClicked = onMultiDeleteClicked,
onEpisodeSelected = onEpisodeSelected,
onAllEpisodeSelected = onAllEpisodeSelected,
onInvertSelection = onInvertSelection,
)
}
}
@ -198,9 +183,9 @@ private fun AnimeScreenSmallImpl(
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onFilterClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onContinueWatching: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
@ -215,121 +200,64 @@ private fun AnimeScreenSmallImpl(
// For bottom action menu
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsRead: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<Episode>) -> Unit,
// Episode selection
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
onAllEpisodeSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
val layoutDirection = LocalLayoutDirection.current
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val scrollBehavior = ExitUntilCollapsedScrollBehavior(rememberTopAppBarScrollState(), decayAnimationSpec)
val episodeListState = rememberLazyListState()
SideEffect {
if (episodeListState.firstVisibleItemIndex > 0 || episodeListState.firstVisibleItemScrollOffset > 0) {
// Should go here after a configuration change
// Safe to say that the app bar is fully scrolled
scrollBehavior.state.offset = scrollBehavior.state.offsetLimit
}
}
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(1) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingEpisode),
onRefresh = onRefresh,
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(LocalDensity.current) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val episodes = remember(state) { state.processedEpisodes.toList() }
val selected = remember(episodes) { emptyList<EpisodeItem>().toMutableStateList() }
val selectedPositions = remember(episodes) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
if (episodes.any { it.selected }) {
onAllEpisodeSelected(false)
} else {
onBackClicked()
}
}
BackHandler(onBack = internalOnBackPressed)
Scaffold(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(insetPadding),
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues()),
topBar = {
AnimeTopAppBar(
modifier = Modifier
.scrollable(
state = rememberScrollableState {
var consumed = runBlocking { episodeListState.scrollBy(-it) } * -1
if (consumed == 0f) {
// Pass scroll to app bar if we're on the top of the list
val newOffset =
(scrollBehavior.state.offset + it).coerceIn(scrollBehavior.state.offsetLimit, 0f)
consumed = newOffset - scrollBehavior.state.offset
scrollBehavior.state.offset = newOffset
val firstVisibleItemIndex by remember {
derivedStateOf { episodeListState.firstVisibleItemIndex }
}
consumed
},
orientation = Orientation.Vertical,
interactionSource = episodeListState.interactionSource as MutableInteractionSource,
),
val firstVisibleItemScrollOffset by remember {
derivedStateOf { episodeListState.firstVisibleItemScrollOffset }
}
val animatedTitleAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0) 1f else 0f,
)
val animatedBgAlpha by animateFloatAsState(
if (firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0) 1f else 0f,
)
MangaToolbar(
title = state.anime.title,
author = state.anime.author,
artist = state.anime.artist,
description = state.anime.description,
tagsProvider = { state.anime.genre },
coverDataProvider = { state.anime },
sourceName = remember { state.source.getNameForAnimeInfo() },
isStubSource = remember { state.source is SourceManager.StubSource },
favorite = state.anime.favorite,
status = state.anime.status,
trackingCount = state.trackingCount,
episodeCount = episodes.size,
episodeFiltered = state.anime.episodesFiltered(),
titleAlphaProvider = { animatedTitleAlpha },
backgroundAlphaProvider = { animatedBgAlpha },
hasFilters = state.anime.episodesFiltered(),
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
fromSource = state.isFromSource,
onBackClicked = internalOnBackPressed,
onCoverClick = onCoverClicked,
onTagClicked = onTagClicked,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onFilterButtonClicked = onFilterButtonClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro,
doGlobalSearch = onSearch,
scrollBehavior = scrollBehavior,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(episodes)
},
onInvertSelection = {
val toSelect = episodes - selected
selected.clear()
selected.addAll(toSelect)
},
onSmallAppBarHeightChanged = onTopBarHeightChanged,
onClickFilter = onFilterClicked,
onClickShare = onShareClicked,
onClickDownload = onDownloadActionClicked,
onClickEditCategory = onEditCategoryClicked,
onClickMigrate = onMigrateClicked,
actionModeCounter = episodes.count { it.selected },
onSelectAll = { onAllEpisodeSelected(true) },
onInvertSelection = { onInvertSelection() },
)
},
bottomBar = {
SharedAnimeBottomActionMenu(
selected = selected,
selected = episodes.filter { it.selected },
onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -342,7 +270,7 @@ private fun AnimeScreenSmallImpl(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = episodes.any { !it.episode.seen } && selected.isEmpty(),
visible = episodes.any { !it.episode.seen } && episodes.none { it.selected },
enter = fadeIn(),
exit = fadeOut(),
) {
@ -355,35 +283,102 @@ private fun AnimeScreenSmallImpl(
}
Text(text = stringResource(id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
icon = {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
)
},
onClick = onContinueWatching,
expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
.padding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
.asPaddingValues(),
),
)
}
},
) { contentPadding ->
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
val topPadding = contentPadding.calculateTopPadding()
SwipeRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = episodes.none { it.selected },
indicatorPadding = contentPadding,
) {
VerticalFastScroller(
listState = episodeListState,
thumbAllowed = { scrollBehavior.state.offset == scrollBehavior.state.offsetLimit },
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
topContentPadding = topPadding,
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = episodeListState,
contentPadding = withNavBarContentPadding,
contentPadding = PaddingValues(
bottom = contentPadding.calculateBottomPadding(),
),
) {
item(
key = MangaScreenItem.INFO_BOX,
contentType = MangaScreenItem.INFO_BOX,
) {
AnimeInfoBox(
isTabletUi = false,
appBarPadding = topPadding,
title = state.anime.title,
author = state.anime.author,
artist = state.anime.artist,
sourceName = remember { state.source.getNameForAnimeInfo() },
isStubSource = remember { state.source is AnimeSourceManager.StubAnimeSource },
coverDataProvider = { state.anime },
status = state.anime.status,
onCoverClick = onCoverClicked,
doSearch = onSearch,
)
}
item(
key = MangaScreenItem.ACTION_ROW,
contentType = MangaScreenItem.ACTION_ROW,
) {
AnimeActionRow(
favorite = state.anime.favorite,
trackingCount = state.trackingCount,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onEditCategory = onEditCategoryClicked,
)
}
item(
key = MangaScreenItem.DESCRIPTION_WITH_TAG,
contentType = MangaScreenItem.DESCRIPTION_WITH_TAG,
) {
ExpandableAnimeDescription(
defaultExpandState = state.isFromSource,
description = state.anime.description,
tagsProvider = { state.anime.genre },
onTagClicked = onTagClicked,
)
}
item(
key = MangaScreenItem.CHAPTER_HEADER,
contentType = MangaScreenItem.CHAPTER_HEADER,
) {
EpisodeHeader(
episodeCount = episodes.size,
onClick = onFilterClicked,
)
}
sharedEpisodeItems(
episodes = episodes,
state = state,
selected = selected,
selectedPositions = selectedPositions,
onEpisodeClicked = onEpisodeClicked,
onDownloadEpisode = onDownloadEpisode,
onEpisodeSelected = onEpisodeSelected,
)
}
}
@ -394,7 +389,6 @@ private fun AnimeScreenSmallImpl(
@Composable
fun AnimeScreenLargeImpl(
state: AnimeScreenState.Success,
windowWidthSizeClass: WindowWidthSizeClass,
snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit,
@ -405,7 +399,7 @@ fun AnimeScreenLargeImpl(
onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit,
onContinueReading: () -> Unit,
onContinueWatching: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog
@ -420,40 +414,37 @@ fun AnimeScreenLargeImpl(
// For bottom action menu
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsRead: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<Episode>) -> Unit,
// Episode selection
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
onAllEpisodeSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) {
val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current
val episodes = remember(state) { state.processedEpisodes.toList() }
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) }
var topBarHeight by remember { mutableStateOf(0) }
SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingEpisode),
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = episodes.none { it.selected },
indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection),
),
clipIndicatorToPadding = true,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) {
val episodeListState = rememberLazyListState()
val episodes = remember(state) { state.processedEpisodes.toList() }
val selected = remember(episodes) { emptyList<EpisodeItem>().toMutableStateList() }
val selectedPositions = remember(episodes) { arrayOf(-1, -1) } // first and last selected index in list
val internalOnBackPressed = {
if (selected.isNotEmpty()) {
selected.clear()
if (episodes.any { it.selected }) {
onAllEpisodeSelected(false)
} else {
onBackClicked()
}
@ -463,29 +454,23 @@ fun AnimeScreenLargeImpl(
Scaffold(
modifier = Modifier.padding(insetPadding),
topBar = {
AnimeSmallAppBar(
modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) },
MangaToolbar(
modifier = Modifier.onSizeChanged { topBarHeight = (it.height) },
title = state.anime.title,
titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f },
titleAlphaProvider = { if (episodes.any { it.selected }) 1f else 0f },
backgroundAlphaProvider = { 1f },
hasFilters = state.anime.episodesFiltered(),
incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode,
onBackClicked = internalOnBackPressed,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadActionClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro,
actionModeCounter = selected.size,
onSelectAll = {
selected.clear()
selected.addAll(episodes)
},
onInvertSelection = {
val toSelect = episodes - selected
selected.clear()
selected.addAll(toSelect)
},
onClickFilter = onFilterButtonClicked,
onClickShare = onShareClicked,
onClickDownload = onDownloadActionClicked,
onClickEditCategory = onEditCategoryClicked,
onClickMigrate = onMigrateClicked,
actionModeCounter = episodes.count { it.selected },
onSelectAll = { onAllEpisodeSelected(true) },
onInvertSelection = { onInvertSelection() },
)
},
bottomBar = {
@ -494,7 +479,7 @@ fun AnimeScreenLargeImpl(
contentAlignment = Alignment.BottomEnd,
) {
SharedAnimeBottomActionMenu(
selected = selected,
selected = episodes.filter { it.selected },
onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -508,7 +493,7 @@ fun AnimeScreenLargeImpl(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = {
AnimatedVisibility(
visible = episodes.any { !it.episode.seen } && selected.isEmpty(),
visible = episodes.any { !it.episode.seen } && episodes.none { it.selected },
enter = fadeIn(),
exit = fadeOut(),
) {
@ -521,8 +506,8 @@ fun AnimeScreenLargeImpl(
}
Text(text = stringResource(id))
},
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) },
onClick = onContinueReading,
icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueWatching,
expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(),
modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
@ -530,75 +515,82 @@ fun AnimeScreenLargeImpl(
}
},
) { contentPadding ->
Row {
val withNavBarContentPadding = contentPadding +
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
AnimeInfoHeader(
TwoPanelBox(
startContent = {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(bottom = withNavBarContentPadding.calculateBottomPadding()),
windowWidthSizeClass = WindowWidthSizeClass.Expanded,
.verticalScroll(rememberScrollState()),
) {
AnimeInfoBox(
isTabletUi = true,
appBarPadding = contentPadding.calculateTopPadding(),
title = state.anime.title,
author = state.anime.author,
artist = state.anime.artist,
description = state.anime.description,
tagsProvider = { state.anime.genre },
sourceName = remember { state.source.getNameForAnimeInfo() },
isStubSource = remember { state.source is AnimeSourceManager.StubSource },
isStubSource = remember { state.source is AnimeSourceManager.StubAnimeSource },
coverDataProvider = { state.anime },
favorite = state.anime.favorite,
status = state.anime.status,
trackingCount = state.trackingCount,
fromSource = state.isFromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClicked,
doSearch = onSearch,
)
val episodesWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f
AnimeActionRow(
favorite = state.anime.favorite,
trackingCount = state.trackingCount,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onEditCategory = onEditCategoryClicked,
)
ExpandableAnimeDescription(
defaultExpandState = true,
description = state.anime.description,
tagsProvider = { state.anime.genre },
onTagClicked = onTagClicked,
)
}
},
endContent = {
VerticalFastScroller(
listState = episodeListState,
modifier = Modifier.weight(episodesWeight),
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
topContentPadding = contentPadding.calculateTopPadding(),
) {
LazyColumn(
modifier = Modifier.fillMaxHeight(),
state = episodeListState,
contentPadding = withNavBarContentPadding,
contentPadding = PaddingValues(
top = contentPadding.calculateTopPadding(),
bottom = contentPadding.calculateBottomPadding(),
),
) {
item(
key = MangaScreenItem.CHAPTER_HEADER,
contentType = MangaScreenItem.CHAPTER_HEADER,
) {
item(contentType = "header") {
EpisodeHeader(
episodeCount = episodes.size,
isEpisodeFiltered = state.anime.episodesFiltered(),
onFilterButtonClicked = onFilterButtonClicked,
onClick = onFilterButtonClicked,
)
}
sharedEpisodeItems(
episodes = episodes,
state = state,
selected = selected,
selectedPositions = selectedPositions,
onEpisodeClicked = onEpisodeClicked,
onDownloadEpisode = onDownloadEpisode,
onEpisodeSelected = onEpisodeSelected,
)
}
}
}
},
)
}
}
}
@Composable
private fun SharedAnimeBottomActionMenu(
selected: SnapshotStateList<EpisodeItem>,
selected: List<EpisodeItem>,
modifier: Modifier = Modifier,
onEpisodeClicked: (Episode, Boolean) -> Unit,
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
@ -607,225 +599,97 @@ private fun SharedAnimeBottomActionMenu(
onMultiDeleteClicked: (List<Episode>) -> Unit,
fillFraction: Float,
) {
val preferences: PreferencesHelper = Injekt.get()
val preferences: PlayerPreferences = Injekt.get()
AnimeBottomActionMenu(
visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(fillFraction),
modifier = modifier.fillMaxWidth(fillFraction),
onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.episode }, true)
selected.clear()
}.takeIf { selected.any { !it.episode.bookmark } },
onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.episode }, false)
selected.clear()
}.takeIf { selected.all { it.episode.bookmark } },
onMarkAsSeenClicked = {
onMultiMarkAsSeenClicked(selected.map { it.episode }, true)
selected.clear()
}.takeIf { selected.any { !it.episode.seen } },
onMarkAsUnseenClicked = {
onMultiMarkAsSeenClicked(selected.map { it.episode }, false)
selected.clear()
}.takeIf { selected.any { it.episode.seen } },
}.takeIf { selected.any { it.episode.seen || it.episode.lastSecondSeen > 0L } },
onMarkPreviousAsSeenClicked = {
onMarkPreviousAsSeenClicked(selected[0].episode)
selected.clear()
}.takeIf { selected.size == 1 },
onDownloadClicked = {
onDownloadEpisode!!(selected.toList(), EpisodeDownloadAction.START)
selected.clear()
}.takeIf {
onDownloadEpisode != null && selected.any { it.downloadState != AnimeDownload.State.DOWNLOADED }
},
onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.episode })
selected.clear()
}.takeIf {
onDownloadEpisode != null && selected.any { it.downloadState == AnimeDownload.State.DOWNLOADED }
},
onExternalClicked = {
onEpisodeClicked(selected.map { it.episode }.first(), true)
selected.clear()
}.takeIf { !preferences.alwaysUseExternalPlayer() && selected.size == 1 },
}.takeIf { !preferences.alwaysUseExternalPlayer().get() && selected.size == 1 },
onInternalClicked = {
onEpisodeClicked(selected.map { it.episode }.first(), true)
selected.clear()
}.takeIf { preferences.alwaysUseExternalPlayer() && selected.size == 1 },
}.takeIf { preferences.alwaysUseExternalPlayer().get() && selected.size == 1 },
)
}
private fun LazyListScope.sharedEpisodeItems(
episodes: List<EpisodeItem>,
state: AnimeScreenState.Success,
selected: SnapshotStateList<EpisodeItem>,
selectedPositions: Array<Int>,
onEpisodeClicked: (Episode, Boolean) -> Unit,
onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?,
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
) {
items(items = episodes) { episodeItem ->
val context = LocalContext.current
items(
items = episodes,
key = { "episode-${it.episode.id}" },
contentType = { MangaScreenItem.CHAPTER },
) { episodeItem ->
val haptic = LocalHapticFeedback.current
val (episode, downloadState, downloadProgress) = episodeItem
val episodeTitle = if (state.anime.displayMode == EPISODE_DISPLAY_NUMBER) {
stringResource(
id = R.string.display_mode_episode,
episodeDecimalFormat.format(episode.episodeNumber.toDouble()),
)
} else {
episode.name
}
val date = remember(episode.dateUpload) {
episode.dateUpload
.takeIf { it > 0 }
?.let {
Date(it).toRelativeString(
context,
state.dateRelativeTime,
state.dateFormat,
)
}
}
val lastSecondSeen = remember(episode.lastSecondSeen, episode.seen) {
episode.lastSecondSeen.takeIf { !episode.seen && it > 0 }
}
val totalSeconds = remember(episode.totalSeconds) {
episode.totalSeconds.takeIf { !episode.seen && it > 0 }
}
val scanlator = remember(episode.scanlator) { episode.scanlator.takeIf { !it.isNullOrBlank() } }
AnimeEpisodeListItem(
title = episodeTitle,
date = date,
watchProgress = lastSecondSeen?.let {
if (totalSeconds != null) {
stringResource(
id = R.string.episode_progress,
formatProgress(lastSecondSeen),
formatProgress(totalSeconds),
)
} else {
stringResource(
id = R.string.episode_progress_no_total,
formatProgress(lastSecondSeen),
)
}
},
scanlator = scanlator,
seen = episode.seen,
bookmark = episode.bookmark,
selected = selected.contains(episodeItem),
downloadState = downloadState,
downloadProgress = downloadProgress,
title = episodeItem.episodeTitleString,
date = episodeItem.dateUploadString,
watchProgress = episodeItem.seenProgressString,
scanlator = episodeItem.episode.scanlator.takeIf { !it.isNullOrBlank() },
seen = episodeItem.episode.seen,
bookmark = episodeItem.episode.bookmark,
selected = episodeItem.selected,
downloadStateProvider = { episodeItem.downloadState },
downloadProgressProvider = { episodeItem.downloadProgress },
onLongClick = {
val dispatched = onEpisodeItemLongClick(
episodeItem = episodeItem,
selected = selected,
episodes = episodes,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onEpisodeSelected(episodeItem, !episodeItem.selected, true, true)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
},
onClick = {
onEpisodeItemClick(
episodeItem = episodeItem,
selected = selected,
episodes = episodes,
selectedPositions = selectedPositions,
onToggleSelection = { onEpisodeSelected(episodeItem, !episodeItem.selected, true, false) },
onEpisodeClicked = onEpisodeClicked,
)
},
onDownloadClick = if (onDownloadEpisode != null) {
{ onDownloadEpisode(listOf(episodeItem), it) }
} else null,
} else {
null
},
)
}
}
private fun onEpisodeItemLongClick(
episodeItem: EpisodeItem,
selected: MutableList<EpisodeItem>,
episodes: List<EpisodeItem>,
selectedPositions: Array<Int>,
): Boolean {
if (!selected.contains(episodeItem)) {
val selectedIndex = episodes.indexOf(episodeItem)
if (selected.isEmpty()) {
selected.add(episodeItem)
selectedPositions[0] = selectedIndex
selectedPositions[1] = selectedIndex
return true
}
// Try to select the items in-between when possible
val range: IntRange
if (selectedIndex < selectedPositions[0]) {
range = selectedIndex until selectedPositions[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = selectedIndex..selectedIndex
}
range.forEach {
val toAdd = episodes[it]
if (!selected.contains(toAdd)) {
selected.add(toAdd)
}
}
return true
}
return false
}
private fun onEpisodeItemClick(
episodeItem: EpisodeItem,
selected: MutableList<EpisodeItem>,
episodes: List<EpisodeItem>,
selectedPositions: Array<Int>,
onToggleSelection: (Boolean) -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit,
) {
val selectedIndex = episodes.indexOf(episodeItem)
when {
selected.contains(episodeItem) -> {
val removedIndex = episodes.indexOf(episodeItem)
selected.remove(episodeItem)
if (removedIndex == selectedPositions[0]) {
selectedPositions[0] = episodes.indexOfFirst { selected.contains(it) }
} else if (removedIndex == selectedPositions[1]) {
selectedPositions[1] = episodes.indexOfLast { selected.contains(it) }
}
}
selected.isNotEmpty() -> {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
selectedPositions[1] = selectedIndex
}
selected.add(episodeItem)
}
episodeItem.selected -> onToggleSelection(false)
episodes.any { it.selected } -> onToggleSelection(true)
else -> onEpisodeClicked(episodeItem.episode, false)
}
}
private fun formatProgress(milliseconds: Long): String {
return if (milliseconds > 3600000L) String.format(
"%d:%02d:%02d",
TimeUnit.MILLISECONDS.toHours(milliseconds),
TimeUnit.MILLISECONDS.toMinutes(milliseconds) -
TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(milliseconds)),
TimeUnit.MILLISECONDS.toSeconds(milliseconds) -
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(milliseconds)),
) else {
String.format(
"%d:%02d",
TimeUnit.MILLISECONDS.toMinutes(milliseconds),
TimeUnit.MILLISECONDS.toSeconds(milliseconds) -
TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(milliseconds)),
)
}
}

View file

@ -14,7 +14,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Share
@ -24,11 +24,14 @@ 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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updatePadding
@ -63,7 +66,7 @@ fun AnimeCoverDialog(
) {
IconButton(onClick = onDismissRequest) {
Icon(
imageVector = Icons.Default.Close,
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_close),
)
}
@ -82,9 +85,15 @@ fun AnimeCoverDialog(
}
if (onEditClick != null) {
Box {
val (expanded, onExpand) = remember { mutableStateOf(false) }
var expanded by remember { mutableStateOf(false) }
IconButton(
onClick = { if (isCustomCover) onExpand(true) else onEditClick(EditCoverAction.EDIT) },
onClick = {
if (isCustomCover) {
expanded = true
} else {
onEditClick(EditCoverAction.EDIT)
}
},
) {
Icon(
imageVector = Icons.Outlined.Edit,
@ -93,20 +102,21 @@ fun AnimeCoverDialog(
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { onExpand(false) },
onDismissRequest = { expanded = false },
offset = DpOffset(8.dp, 0.dp),
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit)) },
onClick = {
onEditClick(EditCoverAction.EDIT)
onExpand(false)
expanded = false
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_delete)) },
onClick = {
onEditClick(EditCoverAction.DELETE)
onExpand(false)
expanded = false
},
)
}

View file

@ -0,0 +1,39 @@
package eu.kanade.presentation.anime.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import eu.kanade.tachiyomi.R
@Composable
fun DeleteEpisodesDialog(
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(R.string.action_cancel))
}
},
confirmButton = {
TextButton(
onClick = {
onDismissRequest()
onConfirm()
},
) {
Text(text = stringResource(android.R.string.ok))
}
},
title = {
Text(text = stringResource(R.string.are_you_sure))
},
text = {
Text(text = stringResource(R.string.confirm_delete_episodes))
},
)
}

View file

@ -29,11 +29,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.EpisodeDownloadAction
import eu.kanade.presentation.components.EpisodeDownloadIndicator
import eu.kanade.presentation.manga.EpisodeDownloadAction
import eu.kanade.presentation.manga.components.DotSeparatorText
import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.AnimeDownload
import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload
@Composable
fun AnimeEpisodeListItem(
@ -45,8 +46,8 @@ fun AnimeEpisodeListItem(
seen: Boolean,
bookmark: Boolean,
selected: Boolean,
downloadState: AnimeDownload.State,
downloadProgress: Int,
downloadStateProvider: () -> AnimeDownload.State,
downloadProgressProvider: () -> Int,
onLongClick: () -> Unit,
onClick: () -> Unit,
onDownloadClick: ((EpisodeDownloadAction) -> Unit)?,
@ -66,13 +67,13 @@ fun AnimeEpisodeListItem(
} else {
MaterialTheme.colorScheme.onSurface
}
val textAlpha = remember(seen) { if (seen) SeenItemAlpha else 1f }
val textAlpha = remember(seen) { if (seen) ReadItemAlpha else 1f }
Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) }
if (bookmark) {
Icon(
imageVector = Icons.Default.Bookmark,
imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(R.string.action_filter_bookmarked),
modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
@ -82,8 +83,8 @@ fun AnimeEpisodeListItem(
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium
.copy(color = textColor),
color = textColor,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height },
@ -109,7 +110,7 @@ fun AnimeEpisodeListItem(
text = watchProgress,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(SeenItemAlpha),
modifier = Modifier.alpha(ReadItemAlpha),
)
if (scanlator != null) DotSeparatorText()
}
@ -128,12 +129,10 @@ fun AnimeEpisodeListItem(
if (onDownloadClick != null) {
EpisodeDownloadIndicator(
modifier = Modifier.padding(start = 4.dp),
downloadState = downloadState,
downloadProgress = downloadProgress,
downloadStateProvider = downloadStateProvider,
downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadClick,
)
}
}
}
private const val SeenItemAlpha = .38f

View file

@ -21,19 +21,20 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.AttachMoney
import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Pause
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
@ -42,7 +43,6 @@ import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@ -62,6 +62,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@ -76,7 +77,6 @@ import eu.kanade.presentation.components.MangaCover
import eu.kanade.presentation.components.TextButton
import eu.kanade.presentation.manga.components.DotSeparatorText
import eu.kanade.presentation.util.clickableNoIndication
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.model.SAnime
@ -86,33 +86,21 @@ import kotlin.math.roundToInt
private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@Composable
fun AnimeInfoHeader(
fun AnimeInfoBox(
modifier: Modifier = Modifier,
windowWidthSizeClass: WindowWidthSizeClass,
isTabletUi: Boolean,
appBarPadding: Dp,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
sourceName: String,
isStubSource: Boolean,
coverDataProvider: () -> Anime,
favorite: Boolean,
status: Long,
trackingCount: Int,
fromSource: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onEditCategory: (() -> Unit)?,
onCoverClick: () -> Unit,
doSearch: (query: String, global: Boolean) -> Unit,
) {
val context = LocalContext.current
Column(modifier = modifier) {
Box {
Box(modifier = modifier) {
// Backdrop
val backdropGradientColors = listOf(
Color.Transparent,
@ -133,15 +121,15 @@ fun AnimeInfoHeader(
.alpha(.2f),
)
// Anime & source info
// Manga & source info
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) {
if (!isTabletUi) {
AnimeAndSourceTitlesSmall(
appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
context = LocalContext.current,
doSearch = doSearch,
author = author,
artist = artist,
@ -155,7 +143,7 @@ fun AnimeInfoHeader(
coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick,
title = title,
context = context,
context = LocalContext.current,
doSearch = doSearch,
author = author,
artist = artist,
@ -166,9 +154,19 @@ fun AnimeInfoHeader(
}
}
}
}
// Action buttons
Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
@Composable
fun AnimeActionRow(
modifier: Modifier = Modifier,
favorite: Boolean,
trackingCount: Int,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onEditCategory: (() -> Unit)?,
) {
Row(modifier = modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) {
val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
AnimeActionButton(
title = if (favorite) {
@ -176,7 +174,7 @@ fun AnimeInfoHeader(
} else {
stringResource(R.string.add_to_library)
},
icon = if (favorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
icon = if (favorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onAddToLibraryClicked,
onLongClick = onEditCategory,
@ -186,9 +184,9 @@ fun AnimeInfoHeader(
title = if (trackingCount == 0) {
stringResource(R.string.manga_tracking_tab)
} else {
quantityStringResource(id = R.plurals.num_trackers, quantity = trackingCount, trackingCount)
pluralStringResource(id = R.plurals.num_trackers, count = trackingCount, trackingCount)
},
icon = if (trackingCount == 0) Icons.Default.Sync else Icons.Default.Done,
icon = if (trackingCount == 0) Icons.Outlined.Sync else Icons.Outlined.Done,
color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary,
onClick = onTrackingClicked,
)
@ -196,20 +194,28 @@ fun AnimeInfoHeader(
if (onWebViewClicked != null) {
AnimeActionButton(
title = stringResource(R.string.action_web_view),
icon = Icons.Default.Public,
icon = Icons.Outlined.Public,
color = defaultActionButtonColor,
onClick = onWebViewClicked,
)
}
}
}
// Expandable description-tags
Column {
@Composable
fun ExpandableAnimeDescription(
modifier: Modifier = Modifier,
defaultExpandState: Boolean,
description: String?,
tagsProvider: () -> List<String>?,
onTagClicked: (String) -> Unit,
) {
Column(modifier = modifier) {
val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact)
mutableStateOf(defaultExpandState)
}
val desc =
description.takeIf { !it.isNullOrBlank() } ?: stringResource(id = R.string.description_placeholder)
description.takeIf { !it.isNullOrBlank() } ?: stringResource(R.string.description_placeholder)
val trimmedDescription = remember(desc) {
desc
.replace(whitespaceLineRegex, "\n")
@ -222,10 +228,7 @@ fun AnimeInfoHeader(
modifier = Modifier
.padding(top = 8.dp)
.padding(horizontal = 16.dp)
.clickableNoIndication(
onLongClick = { context.copyToClipboard(desc, desc) },
onClick = { onExpanded(!expanded) },
),
.clickableNoIndication { onExpanded(!expanded) },
)
val tags = tagsProvider()
if (!tags.isNullOrEmpty()) {
@ -265,7 +268,6 @@ fun AnimeInfoHeader(
}
}
}
}
@Composable
private fun AnimeAndSourceTitlesLarge(
@ -288,13 +290,14 @@ private fun AnimeAndSourceTitlesLarge(
horizontalAlignment = Alignment.CenterHorizontally,
) {
MangaCover.Book(
modifier = Modifier.fillMaxWidth(0.4f),
modifier = Modifier.fillMaxWidth(0.65f),
data = coverDataProvider(),
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = title.takeIf { it.isNotBlank() } ?: stringResource(R.string.unknown),
text = title.ifBlank { stringResource(R.string.unknown_title) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
@ -311,10 +314,12 @@ private fun AnimeAndSourceTitlesLarge(
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
if (!author.isNullOrBlank()) {
context.copyToClipboard(
author,
author,
)
}
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
@ -341,13 +346,13 @@ private fun AnimeAndSourceTitlesLarge(
) {
Icon(
imageVector = when (status) {
SAnime.ONGOING.toLong() -> Icons.Default.Schedule
SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll
SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SAnime.CANCELLED.toLong() -> Icons.Default.Close
SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule
SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll
SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done
SAnime.CANCELLED.toLong() -> Icons.Outlined.Close
SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause
else -> Icons.Outlined.Block
},
contentDescription = null,
modifier = Modifier
@ -371,7 +376,7 @@ private fun AnimeAndSourceTitlesLarge(
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
imageVector = Icons.Outlined.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
@ -415,18 +420,21 @@ private fun AnimeAndSourceTitlesSmall(
.sizeIn(maxWidth = 100.dp)
.align(Alignment.Top),
data = coverDataProvider(),
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick,
)
Column(modifier = Modifier.padding(start = 16.dp)) {
Text(
text = title.ifBlank { stringResource(R.string.unknown) },
text = title.ifBlank { stringResource(R.string.unknown_title) },
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication(
onLongClick = {
if (title.isNotBlank()) context.copyToClipboard(
if (title.isNotBlank()) {
context.copyToClipboard(
title,
title,
)
}
},
onClick = { if (title.isNotBlank()) doSearch(title, true) },
),
@ -441,10 +449,12 @@ private fun AnimeAndSourceTitlesSmall(
.padding(top = 2.dp)
.clickableNoIndication(
onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard(
if (!author.isNullOrBlank()) {
context.copyToClipboard(
author,
author,
)
}
},
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
),
@ -469,13 +479,13 @@ private fun AnimeAndSourceTitlesSmall(
) {
Icon(
imageVector = when (status) {
SAnime.ONGOING.toLong() -> Icons.Default.Schedule
SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll
SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done
SAnime.CANCELLED.toLong() -> Icons.Default.Close
SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause
else -> Icons.Default.Block
SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule
SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll
SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done
SAnime.CANCELLED.toLong() -> Icons.Outlined.Close
SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause
else -> Icons.Outlined.Block
},
contentDescription = null,
modifier = Modifier
@ -499,7 +509,7 @@ private fun AnimeAndSourceTitlesSmall(
DotSeparatorText()
if (isStubSource) {
Icon(
imageVector = Icons.Default.Warning,
imageVector = Icons.Outlined.Warning,
contentDescription = null,
modifier = Modifier
.padding(end = 4.dp)
@ -555,6 +565,7 @@ private fun AnimeSummary(
expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0
val actualPlaceable = subcompose("description") {
SelectionContainer {
Text(
text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE,
@ -562,6 +573,7 @@ private fun AnimeSummary(
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(),
)
}
}.map { it.measure(constraints) }
val scrimPlaceable = subcompose("scrim") {
@ -573,7 +585,7 @@ private fun AnimeSummary(
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon(
painter = rememberAnimatedVectorPainter(image, !expanded),
contentDescription = null,
contentDescription = stringResource(if (expanded) R.string.manga_info_collapse else R.string.manga_info_expand),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())),
)

View file

@ -1,245 +0,0 @@
package eu.kanade.presentation.anime.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipToBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallTopAppBar
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.components.DropdownMenu
import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.tachiyomi.R
@Composable
fun AnimeSmallAppBar(
modifier: Modifier = Modifier,
title: String,
titleAlphaProvider: () -> Float,
backgroundAlphaProvider: () -> Float = titleAlphaProvider,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
onBackClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
changeAnimeSkipIntro: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
) {
val isActionMode = actionModeCounter > 0
val backgroundAlpha = if (isActionMode) 1f else backgroundAlphaProvider()
val backgroundColor by TopAppBarDefaults.centerAlignedTopAppBarColors().containerColor(1f)
Column(
modifier = modifier.drawBehind {
drawRect(backgroundColor.copy(alpha = backgroundAlpha))
},
) {
SmallTopAppBar(
modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)),
title = {
Text(
text = if (isActionMode) actionModeCounter.toString() else title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(titleAlphaProvider()),
)
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = if (isActionMode) Icons.Default.Close else Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.abc_action_bar_up_description),
)
}
},
actions = {
if (isActionMode) {
IconButton(onClick = onSelectAll) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = stringResource(R.string.action_select_all),
)
}
IconButton(onClick = onInvertSelection) {
Icon(
imageVector = Icons.Default.FlipToBack,
contentDescription = stringResource(R.string.action_select_inverse),
)
}
} else {
if (onShareClicked != null) {
IconButton(onClick = onShareClicked) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = stringResource(R.string.action_share),
)
}
}
if (onDownloadClicked != null) {
val (downloadExpanded, onDownloadExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onDownloadExpanded(!downloadExpanded) }) {
Icon(
imageVector = Icons.Outlined.Download,
contentDescription = stringResource(R.string.manga_download),
)
}
val onDismissRequest = { onDownloadExpanded(false) }
DropdownMenu(
expanded = downloadExpanded,
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_1_episode)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_1_CHAPTER)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_5_episodes)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_5_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_10_episodes)) },
onClick = {
onDownloadClicked(DownloadAction.NEXT_10_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_custom)) },
onClick = {
onDownloadClicked(DownloadAction.CUSTOM)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_unseen)) },
onClick = {
onDownloadClicked(DownloadAction.UNREAD_CHAPTERS)
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download_all)) },
onClick = {
onDownloadClicked(DownloadAction.ALL_CHAPTERS)
onDismissRequest()
},
)
}
}
}
if (onEditCategoryClicked != null && onMigrateClicked != null) {
val (moreExpanded, onMoreExpanded) = remember { mutableStateOf(false) }
Box {
IconButton(onClick = { onMoreExpanded(!moreExpanded) }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.abc_action_menu_overflow_description),
)
}
val onDismissRequest = { onMoreExpanded(false) }
DropdownMenu(
expanded = moreExpanded,
onDismissRequest = onDismissRequest,
) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit_anime_categories)) },
onClick = {
onEditCategoryClicked()
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_migrate)) },
onClick = {
onMigrateClicked()
onDismissRequest()
},
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_change_intro_length)) },
onClick = {
changeAnimeSkipIntro?.invoke()
onDismissRequest()
},
)
}
}
}
}
},
// Background handled by parent
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
)
if (downloadedOnlyMode) {
Text(
text = stringResource(R.string.label_downloaded_only),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.tertiary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onTertiary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
if (incognitoMode) {
Text(
text = stringResource(R.string.pref_incognito_mode),
modifier = Modifier
.background(color = MaterialTheme.colorScheme.primary)
.fillMaxWidth()
.padding(4.dp),
color = MaterialTheme.colorScheme.onPrimary,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
)
}
}
}

View file

@ -1,145 +0,0 @@
package eu.kanade.presentation.anime.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import eu.kanade.domain.anime.model.Anime
import eu.kanade.presentation.manga.DownloadAction
import kotlin.math.roundToInt
@Composable
fun AnimeTopAppBar(
modifier: Modifier = Modifier,
title: String,
author: String?,
artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
coverDataProvider: () -> Anime,
sourceName: String,
isStubSource: Boolean,
favorite: Boolean,
status: Long,
trackingCount: Int,
episodeCount: Int?,
episodeFiltered: Boolean,
incognitoMode: Boolean,
downloadedOnlyMode: Boolean,
fromSource: Boolean,
onBackClicked: () -> Unit,
onCoverClick: () -> Unit,
onTagClicked: (String) -> Unit,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onFilterButtonClicked: () -> Unit,
onShareClicked: (() -> Unit)?,
onDownloadClicked: ((DownloadAction) -> Unit)?,
onEditCategoryClicked: (() -> Unit)?,
onMigrateClicked: (() -> Unit)?,
changeAnimeSkipIntro: (() -> Unit)?,
doGlobalSearch: (query: String, global: Boolean) -> Unit,
scrollBehavior: TopAppBarScrollBehavior?,
// For action mode
actionModeCounter: Int,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onSmallAppBarHeightChanged: (Int) -> Unit,
) {
val scrollPercentageProvider = { scrollBehavior?.scrollFraction?.coerceIn(0f, 1f) ?: 0f }
val inverseScrollPercentageProvider = { 1f - scrollPercentageProvider() }
Layout(
modifier = modifier,
content = {
val (smallHeightPx, onSmallHeightPxChanged) = remember { mutableStateOf(0) }
Column(modifier = Modifier.layoutId("animeInfo")) {
AnimeInfoHeader(
windowWidthSizeClass = WindowWidthSizeClass.Compact,
appBarPadding = with(LocalDensity.current) { smallHeightPx.toDp() },
title = title,
author = author,
artist = artist,
description = description,
tagsProvider = tagsProvider,
sourceName = sourceName,
isStubSource = isStubSource,
coverDataProvider = coverDataProvider,
favorite = favorite,
status = status,
trackingCount = trackingCount,
fromSource = fromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClick,
doSearch = doGlobalSearch,
)
EpisodeHeader(
episodeCount = episodeCount,
isEpisodeFiltered = episodeFiltered,
onFilterButtonClicked = onFilterButtonClicked,
)
}
AnimeSmallAppBar(
modifier = Modifier
.layoutId("topBar")
.onSizeChanged {
onSmallHeightPxChanged(it.height)
onSmallAppBarHeightChanged(it.height)
},
title = title,
titleAlphaProvider = { if (actionModeCounter == 0) scrollPercentageProvider() else 1f },
incognitoMode = incognitoMode,
downloadedOnlyMode = downloadedOnlyMode,
onBackClicked = onBackClicked,
onShareClicked = onShareClicked,
onDownloadClicked = onDownloadClicked,
onEditCategoryClicked = onEditCategoryClicked,
onMigrateClicked = onMigrateClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro,
actionModeCounter = actionModeCounter,
onSelectAll = onSelectAll,
onInvertSelection = onInvertSelection,
)
},
) { measurables, constraints ->
val animeInfoPlaceable = measurables
.first { it.layoutId == "animeInfo" }
.measure(constraints.copy(maxHeight = Constraints.Infinity))
val topBarPlaceable = measurables
.first { it.layoutId == "topBar" }
.measure(constraints)
val animeInfoHeight = animeInfoPlaceable.height
val topBarHeight = topBarPlaceable.height
val animeInfoSansTopBarHeightPx = animeInfoHeight - topBarHeight
val layoutHeight = topBarHeight +
(animeInfoSansTopBarHeightPx * inverseScrollPercentageProvider()).roundToInt()
layout(constraints.maxWidth, layoutHeight) {
val animeInfoY = (-animeInfoSansTopBarHeightPx * scrollPercentageProvider()).roundToInt()
animeInfoPlaceable.place(0, animeInfoY)
topBarPlaceable.place(0, 0)
// Update offset limit
val offsetLimit = -animeInfoSansTopBarHeightPx.toFloat()
if (scrollBehavior?.state?.offsetLimit != offsetLimit) {
scrollBehavior?.state?.offsetLimit = offsetLimit
}
}
}
}

View file

@ -4,32 +4,25 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.Icon
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
@Composable
fun EpisodeHeader(
episodeCount: Int?,
isEpisodeFiltered: Boolean,
onFilterButtonClicked: () -> Unit,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onFilterButtonClicked)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@ -37,20 +30,11 @@ fun EpisodeHeader(
text = if (episodeCount == null) {
stringResource(R.string.episodes)
} else {
quantityStringResource(id = R.plurals.anime_num_episodes, quantity = episodeCount)
pluralStringResource(id = R.plurals.anime_num_episodes, count = episodeCount, episodeCount)
},
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onBackground,
)
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = stringResource(R.string.action_filter),
tint = if (isEpisodeFiltered) {
Color(LocalContext.current.getResourceColor(R.attr.colorFilterActive))
} else {
MaterialTheme.colorScheme.onBackground
},
)
}
}

View file

@ -1,25 +1,27 @@
package eu.kanade.presentation.browse
package eu.kanade.presentation.animebrowse
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.DisplayMetrics
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Divider
@ -30,58 +32,116 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
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.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.animebrowse.components.AnimeExtensionIcon
import eu.kanade.presentation.browse.NsfwWarningDialog
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.AppBarActions
import eu.kanade.presentation.components.DIVIDER_ALPHA
import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.PreferenceRow
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animeextension.model.AnimeExtension
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.extension.model.AnimeExtension
import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionDetailsPresenter
import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun AnimeExtensionDetailsScreen(
nestedScrollInterop: NestedScrollConnection,
navigateUp: () -> Unit,
presenter: AnimeExtensionDetailsPresenter,
onClickUninstall: () -> Unit,
onClickAppInfo: () -> Unit,
onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) {
val extension = presenter.extension
val uriHandler = LocalUriHandler.current
if (extension == null) {
EmptyScreen(textResource = R.string.empty_screen)
return
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(R.string.label_extension_info),
navigateUp = navigateUp,
actions = {
AppBarActions(
actions = buildList {
if (presenter.extension?.isUnofficial == false) {
add(
AppBar.Action(
title = stringResource(R.string.whats_new),
icon = Icons.Outlined.History,
onClick = { uriHandler.openUri(presenter.getChangelogUrl()) },
),
)
add(
AppBar.Action(
title = stringResource(R.string.action_faq_and_guides),
icon = Icons.Outlined.HelpOutline,
onClick = { uriHandler.openUri(presenter.getReadmeUrl()) },
),
)
}
addAll(
listOf(
AppBar.OverflowAction(
title = stringResource(R.string.action_enable_all),
onClick = { presenter.toggleSources(true) },
),
AppBar.OverflowAction(
title = stringResource(R.string.action_disable_all),
onClick = { presenter.toggleSources(false) },
),
AppBar.OverflowAction(
title = stringResource(R.string.pref_clear_cookies),
onClick = { presenter.clearCookies() },
),
),
)
},
)
},
scrollBehavior = scrollBehavior,
)
},
) { paddingValues ->
AnimeExtensionDetails(paddingValues, presenter, onClickSourcePreferences)
}
}
val sources by presenter.sourcesState.collectAsState()
@Composable
private fun AnimeExtensionDetails(
contentPadding: PaddingValues,
presenter: AnimeExtensionDetailsPresenter,
onClickSourcePreferences: (sourceId: Long) -> Unit,
) {
when {
presenter.isLoading -> LoadingScreen()
presenter.extension == null -> EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
val context = LocalContext.current
val extension = presenter.extension
var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn(
modifier = Modifier.nestedScroll(nestedScrollInterop),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
contentPadding = contentPadding,
) {
when {
extension.isUnofficial ->
@ -97,8 +157,13 @@ fun AnimeExtensionDetailsScreen(
item {
DetailsHeader(
extension = extension,
onClickUninstall = onClickUninstall,
onClickAppInfo = onClickAppInfo,
onClickUninstall = { presenter.uninstallExtension() },
onClickAppInfo = {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this)
}
},
onClickAgeRating = {
showNsfwWarning = true
},
@ -106,14 +171,14 @@ fun AnimeExtensionDetailsScreen(
}
items(
items = sources,
items = presenter.sources,
key = { it.source.id },
) { source ->
SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(),
source = source,
onClickSourcePreferences = onClickSourcePreferences,
onClickSource = onClickSource,
onClickSource = { presenter.toggleSource(it) },
)
}
}
@ -125,23 +190,22 @@ fun AnimeExtensionDetailsScreen(
)
}
}
}
}
@Composable
private fun WarningBanner(@StringRes textRes: Int) {
Box(
Text(
text = stringResource(textRes),
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.error)
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(textRes),
color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun DetailsHeader(
@ -164,7 +228,7 @@ private fun DetailsHeader(
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ExtensionIcon(
AnimeExtensionIcon(
modifier = Modifier
.size(112.dp),
extension = extension,
@ -268,7 +332,9 @@ private fun InfoText(
val clickableModifier = if (onClick != null) {
Modifier.clickable(interactionSource, indication = null) { onClick() }
} else Modifier
} else {
Modifier
}
Column(
modifier = modifier.then(clickableModifier),

View file

@ -0,0 +1,25 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.animeextension.model.AnimeExtension
import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem
@Stable
interface AnimeExtensionDetailsState {
val isLoading: Boolean
val extension: AnimeExtension.Installed?
val sources: List<AnimeExtensionSourceItem>
}
fun AnimeExtensionDetailsState(): AnimeExtensionDetailsState {
return AnimeExtensionDetailsStateImpl()
}
class AnimeExtensionDetailsStateImpl : AnimeExtensionDetailsState {
override var isLoading: Boolean by mutableStateOf(true)
override var extension: AnimeExtension.Installed? by mutableStateOf(null)
override var sources: List<AnimeExtensionSourceItem> by mutableStateOf(emptyList())
}

View file

@ -0,0 +1,104 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.PreferenceRow
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionFilterPresenter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable
fun AnimeExtensionFilterScreen(
navigateUp: () -> Unit,
presenter: AnimeExtensionFilterPresenter,
) {
val context = LocalContext.current
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(R.string.label_extensions),
navigateUp = navigateUp,
scrollBehavior = scrollBehavior,
)
},
) { contentPadding ->
when {
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
SourceFilterContent(
contentPadding = contentPadding,
state = presenter,
onClickLang = {
presenter.toggleLanguage(it)
},
)
}
}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest {
when (it) {
AnimeExtensionFilterPresenter.Event.FailedFetchingLanguages -> {
context.toast(R.string.internal_error)
}
}
}
}
}
@Composable
private fun SourceFilterContent(
contentPadding: PaddingValues,
state: AnimeExtensionFilterState,
onClickLang: (String) -> Unit,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
items(
items = state.items,
) { model ->
ExtensionFilterItem(
modifier = Modifier.animateItemPlacement(),
lang = model.lang,
enabled = model.enabled,
onClickItem = onClickLang,
)
}
}
}
@Composable
private fun ExtensionFilterItem(
modifier: Modifier,
lang: String,
enabled: Boolean,
onClickItem: (String) -> Unit,
) {
PreferenceRow(
modifier = modifier,
title = LocaleHelper.getSourceDisplayName(lang, LocalContext.current),
action = {
Switch(checked = enabled, onCheckedChange = null)
},
onClick = { onClickItem(lang) },
)
}

View file

@ -0,0 +1,25 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeFilterUiModel
@Stable
interface AnimeExtensionFilterState {
val isLoading: Boolean
val items: List<AnimeFilterUiModel>
val isEmpty: Boolean
}
fun AnimeExtensionFilterState(): AnimeExtensionFilterState {
return AnimeExtensionFilterStateImpl()
}
class AnimeExtensionFilterStateImpl : AnimeExtensionFilterState {
override var isLoading: Boolean by mutableStateOf(true)
override var items: List<AnimeFilterUiModel> by mutableStateOf(emptyList())
override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
}

View file

@ -1,59 +1,62 @@
package eu.kanade.presentation.browse
package eu.kanade.presentation.animebrowse
import androidx.compose.animation.core.animateDpAsState
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
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.filled.Close
import androidx.compose.material.icons.outlined.Close
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.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.google.accompanist.flowlayout.FlowRow
import eu.kanade.presentation.animebrowse.components.AnimeExtensionIcon
import eu.kanade.presentation.browse.ExtensionHeader
import eu.kanade.presentation.browse.ExtensionTrustDialog
import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.SwipeRefreshIndicator
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.SwipeRefresh
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.extension.model.AnimeExtension
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.AnimeExtensionsPresenter
import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionState
import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionUiModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun AnimeExtensionScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: AnimeExtensionsPresenter,
contentPadding: PaddingValues,
onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit,
onInstallExtension: (AnimeExtension.Available) -> Unit,
@ -63,21 +66,22 @@ fun AnimeExtensionScreen(
onOpenExtension: (AnimeExtension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
onRefresh: () -> Unit,
onLaunched: () -> Unit,
) {
val state by presenter.state.collectAsState()
val isRefreshing = presenter.isRefreshing
SwipeRefresh(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = rememberSwipeRefreshState(isRefreshing),
indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) },
refreshing = presenter.isRefreshing,
onRefresh = onRefresh,
enabled = !presenter.isLoading,
) {
when (state) {
is ExtensionState.Initialized -> {
ExtensionContent(
items = (state as ExtensionState.Initialized).list,
when {
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(
textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
AnimeExtensionContent(
state = presenter,
contentPadding = contentPadding,
onLongClickItem = onLongClickItem,
onClickItemCancel = onClickItemCancel,
onInstallExtension = onInstallExtension,
@ -86,17 +90,16 @@ fun AnimeExtensionScreen(
onTrustExtension = onTrustExtension,
onOpenExtension = onOpenExtension,
onClickUpdateAll = onClickUpdateAll,
onLaunched = onLaunched,
)
}
ExtensionState.Uninitialized -> {}
}
}
}
@Composable
fun ExtensionContent(
items: List<ExtensionUiModel>,
private fun AnimeExtensionContent(
state: AnimeExtensionsState,
contentPadding: PaddingValues,
onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit,
onInstallExtension: (AnimeExtension.Available) -> Unit,
@ -105,31 +108,29 @@ fun ExtensionContent(
onTrustExtension: (AnimeExtension.Untrusted) -> Unit,
onOpenExtension: (AnimeExtension.Installed) -> Unit,
onClickUpdateAll: () -> Unit,
onLaunched: () -> Unit,
) {
var trustState by remember { mutableStateOf<AnimeExtension.Untrusted?>(null) }
FastScrollLazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues,
contentPadding = contentPadding + topPaddingValues,
) {
items(
items = items,
key = {
when (it) {
is ExtensionUiModel.Header.Resource -> it.textRes
is ExtensionUiModel.Header.Text -> it.text
is ExtensionUiModel.Item -> it.key()
}
},
items = state.items,
contentType = {
when (it) {
is ExtensionUiModel.Item -> "item"
else -> "header"
is AnimeExtensionUiModel.Header -> "header"
is AnimeExtensionUiModel.Item -> "item"
}
},
key = {
when (it) {
is AnimeExtensionUiModel.Header -> "animeextensionHeader-${it.hashCode()}"
is AnimeExtensionUiModel.Item -> "animeextension-${it.hashCode()}"
}
},
) { item ->
when (item) {
is ExtensionUiModel.Header.Resource -> {
is AnimeExtensionUiModel.Header.Resource -> {
val action: @Composable RowScope.() -> Unit =
if (item.textRes == R.string.ext_updates_pending) {
{
@ -151,26 +152,20 @@ fun ExtensionContent(
action = action,
)
}
is ExtensionUiModel.Header.Text -> {
is AnimeExtensionUiModel.Header.Text -> {
ExtensionHeader(
text = item.text,
modifier = Modifier.animateItemPlacement(),
)
}
is ExtensionUiModel.Item -> {
is AnimeExtensionUiModel.Item -> {
AnimeExtensionItem(
modifier = Modifier.animateItemPlacement(),
item = item,
onClickItem = {
when (it) {
is AnimeExtension.Available -> onInstallExtension(it)
is AnimeExtension.Installed -> {
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
onOpenExtension(it)
}
}
is AnimeExtension.Installed -> onOpenExtension(it)
is AnimeExtension.Untrusted -> { trustState = it }
}
},
@ -190,9 +185,6 @@ fun ExtensionContent(
}
},
)
LaunchedEffect(Unit) {
onLaunched()
}
}
}
}
@ -215,9 +207,9 @@ fun ExtensionContent(
}
@Composable
fun AnimeExtensionItem(
private fun AnimeExtensionItem(
modifier: Modifier = Modifier,
item: ExtensionUiModel.Item,
item: AnimeExtensionUiModel.Item,
onClickItem: (AnimeExtension) -> Unit,
onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit,
@ -233,10 +225,30 @@ fun AnimeExtensionItem(
onClickItem = { onClickItem(extension) },
onLongClickItem = { onLongClickItem(extension) },
icon = {
ExtensionIcon(extension = extension)
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)
AnimeExtensionIcon(
extension = extension,
modifier = Modifier
.matchParentSize()
.padding(padding),
)
}
},
action = {
ExtensionItemActions(
AnimeExtensionItemActions(
extension = extension,
installStep = installStep,
onClickItemCancel = onClickItemCancel,
@ -244,29 +256,20 @@ fun AnimeExtensionItem(
)
},
) {
ExtensionItemContent(
AnimeExtensionItemContent(
extension = extension,
installStep = installStep,
modifier = Modifier.weight(1f),
)
}
}
@Composable
fun ExtensionItemContent(
private fun AnimeExtensionItemContent(
extension: AnimeExtension,
installStep: InstallStep,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val warning = remember(extension) {
when {
extension is AnimeExtension.Untrusted -> R.string.ext_untrusted
extension is AnimeExtension.Installed && extension.isUnofficial -> R.string.ext_unofficial
extension is AnimeExtension.Installed && extension.isObsolete -> R.string.ext_obsolete
extension.isNsfw -> R.string.ext_nsfw_short
else -> null
}
}
Column(
modifier = modifier.padding(start = horizontalPadding),
) {
@ -276,56 +279,72 @@ fun ExtensionItemContent(
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
// Won't look good but it's not like we can ellipsize overflowing content
FlowRow(
modifier = Modifier.secondaryItemAlpha(),
mainAxisSpacing = 4.dp,
) {
if (extension.lang.isNullOrEmpty().not()) {
ProvideTextStyle(value = MaterialTheme.typography.bodySmall) {
if (extension is AnimeExtension.Installed && extension.lang.isNotEmpty()) {
Text(
text = LocaleHelper.getSourceDisplayName(extension.lang, context),
style = MaterialTheme.typography.bodySmall,
text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
)
}
if (extension.versionName.isNotEmpty()) {
Text(
text = extension.versionName,
style = MaterialTheme.typography.bodySmall,
)
}
val warning = when {
extension is AnimeExtension.Untrusted -> R.string.ext_untrusted
extension is AnimeExtension.Installed && extension.isUnofficial -> R.string.ext_unofficial
extension is AnimeExtension.Installed && extension.isObsolete -> R.string.ext_obsolete
extension.isNsfw -> R.string.ext_nsfw_short
else -> null
}
if (warning != null) {
Text(
text = stringResource(id = warning).uppercase(),
style = MaterialTheme.typography.bodySmall.copy(
text = stringResource(warning).uppercase(),
color = MaterialTheme.colorScheme.error,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (!installStep.isCompleted()) {
DotSeparatorNoSpaceText()
Text(
text = when (installStep) {
InstallStep.Pending -> stringResource(R.string.ext_pending)
InstallStep.Downloading -> stringResource(R.string.ext_downloading)
InstallStep.Installing -> stringResource(R.string.ext_installing)
else -> error("Must not show non-install process text")
},
)
}
}
}
}
}
@Composable
fun ExtensionItemActions(
private fun AnimeExtensionItemActions(
extension: AnimeExtension,
installStep: InstallStep,
modifier: Modifier = Modifier,
onClickItemCancel: (AnimeExtension) -> Unit = {},
onClickItemAction: (AnimeExtension) -> Unit = {},
) {
val isIdle = remember(installStep) {
installStep == InstallStep.Idle || installStep == InstallStep.Error
}
val isIdle = installStep.isCompleted()
Row(modifier = modifier) {
if (isIdle) {
TextButton(
onClick = { onClickItemAction(extension) },
enabled = isIdle,
) {
Text(
text = when (installStep) {
InstallStep.Pending -> stringResource(R.string.ext_pending)
InstallStep.Downloading -> stringResource(R.string.ext_downloading)
InstallStep.Installing -> stringResource(R.string.ext_installing)
InstallStep.Installed -> stringResource(R.string.ext_installed)
InstallStep.Error -> stringResource(R.string.action_retry)
InstallStep.Idle -> {
@ -341,18 +360,15 @@ fun ExtensionItemActions(
is AnimeExtension.Available -> stringResource(R.string.ext_install)
}
}
else -> error("Must not show install process text")
},
style = LocalTextStyle.current.copy(
color = if (isIdle) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceTint,
),
)
}
if (isIdle.not()) {
} else {
IconButton(onClick = { onClickItemCancel(extension) }) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "",
tint = MaterialTheme.colorScheme.onBackground,
imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_cancel),
)
}
}

View file

@ -0,0 +1,27 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.browse.animeextension.AnimeExtensionUiModel
interface AnimeExtensionsState {
val isLoading: Boolean
val isRefreshing: Boolean
val items: List<AnimeExtensionUiModel>
val updates: Int
val isEmpty: Boolean
}
fun AnimeExtensionState(): AnimeExtensionsState {
return AnimeExtensionsStateImpl()
}
class AnimeExtensionsStateImpl : AnimeExtensionsState {
override var isLoading: Boolean by mutableStateOf(true)
override var isRefreshing: Boolean by mutableStateOf(false)
override var items: List<AnimeExtensionUiModel> by mutableStateOf(emptyList())
override var updates: Int by mutableStateOf(0)
override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
}

View file

@ -0,0 +1,77 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.paging.compose.collectAsLazyPagingItems
import eu.kanade.domain.anime.model.Anime
import eu.kanade.presentation.browse.BrowseSourceFloatingActionButton
import eu.kanade.presentation.browse.components.BrowseSourceSearchToolbar
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.ui.browse.animesource.browse.BrowseAnimeSourcePresenter
import eu.kanade.tachiyomi.ui.more.MoreController
@Composable
fun AnimeSourceSearchScreen(
presenter: BrowseAnimeSourcePresenter,
navigateUp: () -> Unit,
onFabClick: () -> Unit,
onAnimeClick: (Anime) -> Unit,
onWebViewClick: () -> Unit,
) {
val columns by presenter.getColumnsPreferenceForCurrentOrientation()
val mangaList = presenter.getAnimeList().collectAsLazyPagingItems()
val snackbarHostState = remember { SnackbarHostState() }
val uriHandler = LocalUriHandler.current
val onHelpClick = {
uriHandler.openUri(LocalAnimeSource.HELP_URL)
}
Scaffold(
topBar = { scrollBehavior ->
BrowseSourceSearchToolbar(
searchQuery = presenter.searchQuery ?: "",
onSearchQueryChanged = { presenter.searchQuery = it },
placeholderText = stringResource(R.string.action_search_hint),
navigateUp = navigateUp,
onResetClick = { presenter.searchQuery = "" },
onSearchClick = { presenter.search(it) },
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
BrowseSourceFloatingActionButton(
isVisible = presenter.filters.isNotEmpty(),
onFabClick = onFabClick,
)
},
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
) { paddingValues ->
BrowseAnimeSourceContent(
state = presenter,
animeList = mangaList,
getAnimeState = { presenter.getAnime(it) },
columns = columns,
displayMode = presenter.displayMode,
snackbarHostState = snackbarHostState,
contentPadding = paddingValues,
onWebViewClick = onWebViewClick,
onHelpClick = { uriHandler.openUri(MoreController.URL_HELP) },
onLocalAnimeSourceHelpClick = onHelpClick,
onAnimeClick = onAnimeClick,
onAnimeLongClick = onAnimeClick,
)
}
}

View file

@ -0,0 +1,165 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.presentation.animebrowse.components.BaseAnimeSourceItem
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.FastScrollLazyColumn
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.PreferenceRow
import eu.kanade.presentation.components.Scaffold
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.browse.animesource.AnimeFilterUiModel
import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesFilterPresenter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable
fun AnimeSourcesFilterScreen(
navigateUp: () -> Unit,
presenter: AnimeSourcesFilterPresenter,
onClickLang: (String) -> Unit,
onClickSource: (AnimeSource) -> Unit,
) {
val context = LocalContext.current
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(R.string.label_sources),
navigateUp = navigateUp,
scrollBehavior = scrollBehavior,
)
},
) { contentPadding ->
when {
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(
textResource = R.string.source_filter_empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
AnimeSourcesFilterContent(
contentPadding = contentPadding,
state = presenter,
onClickLang = onClickLang,
onClickSource = onClickSource,
)
}
}
}
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
AnimeSourcesFilterPresenter.Event.FailedFetchingLanguages -> {
context.toast(R.string.internal_error)
}
}
}
}
}
@Composable
private fun AnimeSourcesFilterContent(
contentPadding: PaddingValues,
state: AnimeSourcesFilterState,
onClickLang: (String) -> Unit,
onClickSource: (AnimeSource) -> Unit,
) {
FastScrollLazyColumn(
contentPadding = contentPadding,
) {
items(
items = state.items,
contentType = {
when (it) {
is AnimeFilterUiModel.Header -> "header"
is AnimeFilterUiModel.Item -> "item"
}
},
key = {
when (it) {
is AnimeFilterUiModel.Header -> it.hashCode()
is AnimeFilterUiModel.Item -> "source-filter-${it.source.key()}"
}
},
) { model ->
when (model) {
is AnimeFilterUiModel.Header -> AnimeSourcesFilterHeader(
modifier = Modifier.animateItemPlacement(),
language = model.language,
enabled = model.enabled,
onClickItem = onClickLang,
)
is AnimeFilterUiModel.Item -> AnimeSourcesFilterItem(
modifier = Modifier.animateItemPlacement(),
source = model.source,
isEnabled = model.isEnabled,
onClickItem = onClickSource,
)
}
}
}
}
@Composable
fun AnimeSourceFilterHeader(
modifier: Modifier,
language: String,
isEnabled: Boolean,
onClickItem: (String) -> Unit,
) {
PreferenceRow(
modifier = modifier,
title = LocaleHelper.getSourceDisplayName(language, LocalContext.current),
action = {
Switch(checked = isEnabled, onCheckedChange = null)
},
onClick = { onClickItem(language) },
)
}
@Composable
private fun AnimeSourcesFilterHeader(
modifier: Modifier,
language: String,
enabled: Boolean,
onClickItem: (String) -> Unit,
) {
PreferenceRow(
modifier = modifier,
title = LocaleHelper.getSourceDisplayName(language, LocalContext.current),
action = {
Switch(checked = enabled, onCheckedChange = null)
},
onClick = { onClickItem(language) },
)
}
@Composable
private fun AnimeSourcesFilterItem(
modifier: Modifier,
source: AnimeSource,
isEnabled: Boolean,
onClickItem: (AnimeSource) -> Unit,
) {
BaseAnimeSourceItem(
modifier = modifier,
source = source,
showLanguageInContent = false,
onClickItem = { onClickItem(source) },
action = {
Checkbox(checked = isEnabled, onCheckedChange = null)
},
)
}

View file

@ -0,0 +1,23 @@
package eu.kanade.presentation.animebrowse
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.browse.animesource.AnimeFilterUiModel
interface AnimeSourcesFilterState {
val isLoading: Boolean
val items: List<AnimeFilterUiModel>
val isEmpty: Boolean
}
fun AnimeSourcesFilterState(): AnimeSourcesFilterState {
return AnimeSourcesFilterStateImpl()
}
class AnimeSourcesFilterStateImpl : AnimeSourcesFilterState {
override var isLoading: Boolean by mutableStateOf(true)
override var items: List<AnimeFilterUiModel> by mutableStateOf(emptyList())
override val isEmpty: Boolean by derivedStateOf { items.isEmpty() }
}

View file

@ -1,16 +1,10 @@
package eu.kanade.presentation.browse
package eu.kanade.presentation.animebrowse
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
@ -23,73 +17,78 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.kanade.domain.animesource.interactor.GetRemoteAnime
import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.domain.animesource.model.Pin
import eu.kanade.presentation.browse.components.BaseAnimeSourceItem
import eu.kanade.presentation.animebrowse.components.BaseAnimeSourceItem
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.theme.header
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourceState
import eu.kanade.tachiyomi.ui.browse.animesource.AnimeSourcesPresenter
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
@Composable
fun AnimeSourcesScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: AnimeSourcesPresenter,
onClickItem: (AnimeSource) -> Unit,
contentPadding: PaddingValues,
onClickItem: (AnimeSource, String) -> Unit,
onClickDisable: (AnimeSource) -> Unit,
onClickLatest: (AnimeSource) -> Unit,
onClickPin: (AnimeSource) -> Unit,
) {
val state by presenter.state.collectAsState()
when (state) {
is AnimeSourceState.Loading -> LoadingScreen()
is AnimeSourceState.Error -> Text(text = (state as AnimeSourceState.Error).error.message!!)
is AnimeSourceState.Success -> AnimeSourceList(
nestedScrollConnection = nestedScrollInterop,
list = (state as AnimeSourceState.Success).uiModels,
val context = LocalContext.current
when {
presenter.isLoading -> LoadingScreen()
presenter.isEmpty -> EmptyScreen(
textResource = R.string.source_empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
AnimeSourceList(
state = presenter,
contentPadding = contentPadding,
onClickItem = onClickItem,
onClickDisable = onClickDisable,
onClickLatest = onClickLatest,
onClickPin = onClickPin,
)
}
}
@Composable
fun AnimeSourceList(
nestedScrollConnection: NestedScrollConnection,
list: List<AnimeSourceUiModel>,
onClickItem: (AnimeSource) -> Unit,
onClickDisable: (AnimeSource) -> Unit,
onClickLatest: (AnimeSource) -> Unit,
onClickPin: (AnimeSource) -> Unit,
) {
if (list.isEmpty()) {
EmptyScreen(textResource = R.string.source_empty_screen)
return
LaunchedEffect(Unit) {
presenter.events.collectLatest { event ->
when (event) {
AnimeSourcesPresenter.Event.FailedFetchingSources -> {
context.toast(R.string.internal_error)
}
}
}
}
}
val (sourceState, setSourceState) = remember { mutableStateOf<AnimeSource?>(null) }
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
@Composable
private fun AnimeSourceList(
state: AnimeSourcesState,
contentPadding: PaddingValues,
onClickItem: (AnimeSource, String) -> Unit,
onClickDisable: (AnimeSource) -> Unit,
onClickPin: (AnimeSource) -> Unit,
) {
ScrollbarLazyColumn(
contentPadding = contentPadding + topPaddingValues,
) {
items(
items = list,
items = state.items,
contentType = {
when (it) {
is AnimeSourceUiModel.Header -> "header"
@ -99,13 +98,13 @@ fun AnimeSourceList(
key = {
when (it) {
is AnimeSourceUiModel.Header -> it.hashCode()
is AnimeSourceUiModel.Item -> it.source.key()
is AnimeSourceUiModel.Item -> "source-${it.source.key()}"
}
},
) { model ->
when (model) {
is AnimeSourceUiModel.Header -> {
SourceHeader(
AnimeSourceHeader(
modifier = Modifier.animateItemPlacement(),
language = model.language,
)
@ -114,49 +113,60 @@ fun AnimeSourceList(
modifier = Modifier.animateItemPlacement(),
source = model.source,
onClickItem = onClickItem,
onLongClickItem = {
setSourceState(it)
},
onClickLatest = onClickLatest,
onLongClickItem = { state.dialog = AnimeSourcesPresenter.Dialog(it) },
onClickPin = onClickPin,
)
}
}
}
if (sourceState != null) {
if (state.dialog != null) {
val source = state.dialog!!.source
AnimeSourceOptionsDialog(
source = sourceState,
source = source,
onClickPin = {
onClickPin(sourceState)
setSourceState(null)
onClickPin(source)
state.dialog = null
},
onClickDisable = {
onClickDisable(sourceState)
setSourceState(null)
onClickDisable(source)
state.dialog = null
},
onDismiss = { setSourceState(null) },
onDismiss = { state.dialog = null },
)
}
}
@Composable
fun AnimeSourceItem(
private fun AnimeSourceHeader(
modifier: Modifier = Modifier,
language: String,
) {
val context = LocalContext.current
Text(
text = LocaleHelper.getSourceDisplayName(language, context),
modifier = modifier
.padding(horizontal = horizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.header,
)
}
@Composable
private fun AnimeSourceItem(
modifier: Modifier = Modifier,
source: AnimeSource,
onClickItem: (AnimeSource) -> Unit,
onClickItem: (AnimeSource, String) -> Unit,
onLongClickItem: (AnimeSource) -> Unit,
onClickLatest: (AnimeSource) -> Unit,
onClickPin: (AnimeSource) -> Unit,
) {
BaseAnimeSourceItem(
modifier = modifier,
source = source,
onClickItem = { onClickItem(source) },
onClickItem = { onClickItem(source, GetRemoteAnime.QUERY_POPULAR) },
onLongClickItem = { onLongClickItem(source) },
action = { source ->
action = {
if (source.supportsLatest) {
TextButton(onClick = { onClickLatest(source) }) {
TextButton(onClick = { onClickItem(source, GetRemoteAnime.QUERY_LATEST) }) {
Text(
text = stringResource(id = R.string.latest),
style = LocalTextStyle.current.copy(
@ -174,46 +184,24 @@ fun AnimeSourceItem(
}
@Composable
fun AnimeSourceIcon(
source: AnimeSource,
) {
val icon = source.icon
val modifier = Modifier
.height(40.dp)
.aspectRatio(1f)
if (icon != null) {
Image(
bitmap = icon,
contentDescription = "",
modifier = modifier,
)
} else {
Image(
painter = painterResource(id = R.mipmap.ic_local_source),
contentDescription = "",
modifier = modifier,
)
}
}
@Composable
fun AnimeSourcePinButton(
private fun AnimeSourcePinButton(
isPinned: Boolean,
onClick: () -> Unit,
) {
val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground
val description = if (isPinned) R.string.action_unpin else R.string.action_pin
IconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = "",
tint = tint,
contentDescription = stringResource(description),
)
}
}
@Composable
fun AnimeSourceOptionsDialog(
private fun AnimeSourceOptionsDialog(
source: AnimeSource,
onClickPin: () -> Unit,
onClickDisable: () -> Unit,
@ -221,7 +209,7 @@ fun AnimeSourceOptionsDialog(
) {
AlertDialog(
title = {
Text(text = source.nameWithLanguage)
Text(text = source.visualName)
},
text = {
Column {

Some files were not shown because too many files have changed in this diff Show more