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

View file

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

View file

@ -1,40 +1,17 @@
package eu.kanade.data.anime package eu.kanade.data.anime
import eu.kanade.domain.anime.model.Anime import eu.kanade.domain.anime.model.Anime
import eu.kanade.domain.episode.model.Episode import eu.kanade.domain.animelib.model.AnimelibAnime
import eu.kanade.tachiyomi.data.database.models.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 = 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, viewer, episodeFlags, coverLastModified, dateAdded -> { id, source, url, artist, author, description, genre, title, status, thumbnailUrl, favorite, lastUpdate, _, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy ->
Anime( Anime(
id = id, id = id,
source = source, source = source,
favorite = favorite, favorite = favorite,
lastUpdate = lastUpdate ?: 0, lastUpdate = lastUpdate ?: 0,
dateAdded = dateAdded, 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, viewerFlags = viewerFlags,
episodeFlags = episodeFlags, episodeFlags = episodeFlags,
coverLastModified = coverLastModified, coverLastModified = coverLastModified,
@ -46,46 +23,40 @@ val animeEpisodeMapper: (Long, Long, String, String?, String?, String?, List<Str
genre = genre, genre = genre,
status = status, status = status,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
updateStrategy = updateStrategy,
initialized = initialized, 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, UpdateStrategy, Long, Long, Long, Long, Long, Long, Long) -> AnimelibAnime =
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, thumbnailUrl, favorite, lastUpdate, nextUpdate, initialized, viewerFlags, episodeFlags, coverLastModified, dateAdded, updateStrategy, totalCount, seenCount, latestUpload, episodeFetchedAt, lastSeen, bookmarkCount, category ->
{ _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(
AnimelibAnime().apply { anime = animeMapper(
this.id = _id id,
this.source = source source,
this.url = url url,
this.artist = artist artist,
this.author = author author,
this.description = description description,
this.genre = genre?.joinToString() genre,
this.title = title title,
this.status = status.toInt() status,
this.thumbnail_url = thumbnail_url thumbnailUrl,
this.favorite = favorite favorite,
this.last_update = last_update ?: 0 lastUpdate,
this.initialized = initialized nextUpdate,
this.viewer_flags = viewer.toInt() initialized,
this.episode_flags = episode_flags.toInt() viewerFlags,
this.cover_last_modified = cover_last_modified episodeFlags,
this.date_added = date_added coverLastModified,
this.unseenCount = unseen_count.toInt() dateAdded,
this.seenCount = seen_count.toInt() updateStrategy,
this.category = category.toInt() ),
} 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.AnimeDatabaseHandler
import eu.kanade.data.listOfStringsAdapter 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.Anime
import eu.kanade.domain.anime.model.AnimeUpdate import eu.kanade.domain.anime.model.AnimeUpdate
import eu.kanade.domain.anime.repository.AnimeRepository 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.logcat
import eu.kanade.tachiyomi.util.system.toLong
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import logcat.LogPriority import logcat.LogPriority
@ -24,7 +25,11 @@ class AnimeRepositoryImpl(
} }
override suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime? { 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> { override suspend fun getFavorites(): List<Anime> {
@ -32,11 +37,11 @@ class AnimeRepositoryImpl(
} }
override suspend fun getAnimelibAnime(): List<AnimelibAnime> { override suspend fun getAnimelibAnime(): List<AnimelibAnime> {
return handler.awaitList { animesQueries.getAnimelib(animelibAnime) } return handler.awaitList { animelibViewQueries.animelib(animelibAnime) }
} }
override fun getAnimelibAnimeAsFlow(): Flow<List<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>> { override fun getFavoritesBySourceId(sourceId: Long): Flow<List<Anime>> {
@ -69,7 +74,7 @@ class AnimeRepositoryImpl(
} }
override suspend fun insert(anime: Anime): Long? { override suspend fun insert(anime: Anime): Long? {
return handler.awaitOneOrNull { return handler.awaitOneOrNull(inTransaction = true) {
animesQueries.insert( animesQueries.insert(
source = anime.source, source = anime.source,
url = anime.url, url = anime.url,
@ -88,6 +93,7 @@ class AnimeRepositoryImpl(
episodeFlags = anime.episodeFlags, episodeFlags = anime.episodeFlags,
coverLastModified = anime.coverLastModified, coverLastModified = anime.coverLastModified,
dateAdded = anime.dateAdded, dateAdded = anime.dateAdded,
updateStrategy = anime.updateStrategy,
) )
animesQueries.selectLastInsertedRowId() 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 { return try {
partialUpdate(*values.toTypedArray()) partialUpdate(*animeUpdates.toTypedArray())
true true
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) 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) { handler.await(inTransaction = true) {
values.forEach { value -> animeUpdates.forEach { value ->
animesQueries.update( animesQueries.update(
source = value.source, source = value.source,
url = value.url, url = value.url,
@ -134,6 +140,7 @@ class AnimeRepositoryImpl(
coverLastModified = value.coverLastModified, coverLastModified = value.coverLastModified,
dateAdded = value.dateAdded, dateAdded = value.dateAdded,
animeId = value.id, animeId = value.id,
updateStrategy = value.updateStrategy?.let(updateStrategyAdapter::encode),
) )
} }
} }

View file

@ -1,28 +1,21 @@
package eu.kanade.data.animehistory package eu.kanade.data.animehistory
import androidx.paging.PagingSource
import eu.kanade.data.AnimeDatabaseHandler 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.AnimeHistoryUpdate
import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
import eu.kanade.domain.episode.model.Episode
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.flow.Flow
import logcat.LogPriority import logcat.LogPriority
class AnimeHistoryRepositoryImpl( class AnimeHistoryRepositoryImpl(
private val handler: AnimeDatabaseHandler, private val handler: AnimeDatabaseHandler,
) : AnimeHistoryRepository { ) : AnimeHistoryRepository {
override fun getHistory(query: String): PagingSource<Long, AnimeHistoryWithRelations> { override fun getHistory(query: String): Flow<List<AnimeHistoryWithRelations>> {
return handler.subscribeToPagingSource( return handler.subscribeToList {
countQuery = { animehistoryViewQueries.countHistory(query) }, animehistoryViewQueries.animehistory(query, animehistoryWithRelationsMapper)
queryProvider = { limit, offset -> }
animehistoryViewQueries.animehistory(query, limit, offset, animehistoryWithRelationsMapper)
},
)
} }
override suspend fun getLastHistory(): AnimeHistoryWithRelations? { 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) { override suspend fun resetHistory(historyId: Long) {
try { try {
handler.await { animehistoryQueries.resetAnimeHistoryById(historyId) } 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.lang,
source.name, source.name,
supportsLatest = false, 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.data.AnimeDatabaseHandler
import eu.kanade.domain.animesource.model.AnimeSource 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.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.animesource.LocalAnimeSource import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource
class AnimeSourceRepositoryImpl( class AnimeSourceRepositoryImpl(
private val sourceManager: AnimeSourceManager, 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() } val sourceIdWithNonLibraryAnime = handler.subscribeToList { animesQueries.getSourceIdsWithNonLibraryAnime() }
return sourceIdWithNonLibraryAnime.map { sourceId -> return sourceIdWithNonLibraryAnime.map { sourceId ->
sourceId.map { (sourceId, count) -> sourceId.map { (sourceId, count) ->
val source = sourceManager.getOrStub(sourceId) val source = sourceManager.getOrStub(sourceId)
source to count AnimeSourceWithCount(animesourceMapper(source), count)
} }
} }
} }
override suspend fun getAnimeSourceData(id: Long): AnimeSourceData? { override fun search(
return handler.awaitOneOrNull { animesourcesQueries.getAnimeSourceData(id, animesourceDataMapper) } 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) { override fun getPopular(sourceId: Long): AnimeSourcePagingSourceType {
handler.await { animesourcesQueries.upsert(id, lang, name) } 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()) insertValues(*tracks.toTypedArray())
} }
private suspend fun insertValues(vararg values: AnimeTrack) { private suspend fun insertValues(vararg tracks: AnimeTrack) {
handler.await(inTransaction = true) { handler.await(inTransaction = true) {
values.forEach { animeTrack -> tracks.forEach { animeTrack ->
anime_syncQueries.insert( anime_syncQueries.insert(
animeId = animeTrack.animeId, animeId = animeTrack.animeId,
syncId = animeTrack.syncId, 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.Category
import eu.kanade.domain.category.model.CategoryUpdate import eu.kanade.domain.category.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime 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 import kotlinx.coroutines.flow.Flow
class CategoryRepositoryImplAnime( class CategoryRepositoryImplAnime(
private val handler: AnimeDatabaseHandler, private val handler: AnimeDatabaseHandler,
) : CategoryRepositoryAnime { ) : CategoryRepositoryAnime {
override suspend fun get(id: Long): Category? {
return handler.awaitOneOrNull { categoriesQueries.getCategory(id, categoryMapper) }
}
override suspend fun getAll(): List<Category> { override suspend fun getAll(): List<Category> {
return handler.awaitList { categoriesQueries.getCategories(categoryMapper) } return handler.awaitList { categoriesQueries.getCategories(categoryMapper) }
} }
@ -31,29 +35,43 @@ class CategoryRepositoryImplAnime(
} }
} }
@Throws(DuplicateNameException::class) override suspend fun insert(category: Category) {
override suspend fun insert(name: String, order: Long) {
if (checkDuplicateName(name)) throw DuplicateNameException(name)
handler.await { handler.await {
categoriesQueries.insert( categoriesQueries.insert(
name = name, name = category.name,
order = order, order = category.order,
flags = 0L, flags = category.flags,
) )
} }
} }
@Throws(DuplicateNameException::class) override suspend fun updatePartial(update: CategoryUpdate) {
override suspend fun update(payload: CategoryUpdate) {
if (payload.name != null && checkDuplicateName(payload.name)) throw DuplicateNameException(payload.name)
handler.await { 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( categoriesQueries.update(
name = payload.name, name = update.name,
order = payload.order, order = update.order,
flags = payload.flags, flags = update.flags,
categoryId = payload.id, categoryId = update.id,
) )
} }
override suspend fun updateAllFlags(flags: Long?) {
handler.await {
categoriesQueries.updateAllFlags(flags)
}
} }
override suspend fun delete(categoryId: Long) { 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 package eu.kanade.data.episode
import eu.kanade.data.AnimeDatabaseHandler import eu.kanade.data.AnimeDatabaseHandler
import eu.kanade.data.toLong
import eu.kanade.domain.episode.model.Episode import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.EpisodeUpdate import eu.kanade.domain.episode.model.EpisodeUpdate
import eu.kanade.domain.episode.repository.EpisodeRepository import eu.kanade.domain.episode.repository.EpisodeRepository
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.toLong
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import logcat.LogPriority import logcat.LogPriority
@ -83,6 +83,10 @@ class EpisodeRepositoryImpl(
return handler.awaitList { episodesQueries.getEpisodesByAnimeId(animeId, episodeMapper) } 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? { override suspend fun getEpisodeById(id: Long): Episode? {
return handler.awaitOneOrNull { episodesQueries.getEpisodeById(id, episodeMapper) } 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 package eu.kanade.data.source
import androidx.paging.PagingState import androidx.paging.PagingState
import eu.kanade.data.chapter.NoChaptersException
import eu.kanade.domain.source.model.SourcePagingSourceType import eu.kanade.domain.source.model.SourcePagingSourceType
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
@ -22,7 +23,7 @@ abstract class SourcePagingSource(
withIOContext { withIOContext {
requestNextPage(page.toInt()) requestNextPage(page.toInt())
.takeIf { it.mangas.isNotEmpty() } .takeIf { it.mangas.isNotEmpty() }
?: throw NoResultsException() ?: throw NoChaptersException()
} }
} catch (e: Exception) { } catch (e: Exception) {
return LoadResult.Error(e) return LoadResult.Error(e)

View file

@ -2,8 +2,10 @@ package eu.kanade.domain
import eu.kanade.data.anime.AnimeRepositoryImpl import eu.kanade.data.anime.AnimeRepositoryImpl
import eu.kanade.data.animehistory.AnimeHistoryRepositoryImpl import eu.kanade.data.animehistory.AnimeHistoryRepositoryImpl
import eu.kanade.data.animesource.AnimeSourceDataRepositoryImpl
import eu.kanade.data.animesource.AnimeSourceRepositoryImpl import eu.kanade.data.animesource.AnimeSourceRepositoryImpl
import eu.kanade.data.animetrack.AnimeTrackRepositoryImpl import eu.kanade.data.animetrack.AnimeTrackRepositoryImpl
import eu.kanade.data.animeupdates.AnimeUpdatesRepositoryImpl
import eu.kanade.data.category.CategoryRepositoryImpl import eu.kanade.data.category.CategoryRepositoryImpl
import eu.kanade.data.category.CategoryRepositoryImplAnime import eu.kanade.data.category.CategoryRepositoryImplAnime
import eu.kanade.data.chapter.ChapterRepositoryImpl 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.SourceDataRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.data.track.TrackRepositoryImpl 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.GetAnime
import eu.kanade.domain.anime.interactor.GetAnimeWithEpisodes import eu.kanade.domain.anime.interactor.GetAnimeWithEpisodes
import eu.kanade.domain.anime.interactor.GetAnimelibAnime import eu.kanade.domain.anime.interactor.GetAnimelibAnime
import eu.kanade.domain.anime.interactor.GetDuplicateLibraryAnime 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.SetAnimeEpisodeFlags
import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags
import eu.kanade.domain.anime.interactor.UpdateAnime 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.animedownload.interactor.DeleteAnimeDownload
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionLanguages
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionSources
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionUpdates import eu.kanade.domain.animeextension.interactor.GetAnimeExtensionsByType
import eu.kanade.domain.animeextension.interactor.GetAnimeExtensions import eu.kanade.domain.animehistory.interactor.DeleteAllAnimeHistory
import eu.kanade.domain.animehistory.interactor.DeleteAnimeHistoryTable
import eu.kanade.domain.animehistory.interactor.GetAnimeHistory import eu.kanade.domain.animehistory.interactor.GetAnimeHistory
import eu.kanade.domain.animehistory.interactor.GetNextEpisode import eu.kanade.domain.animehistory.interactor.GetNextEpisode
import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryByAnimeId import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryByAnimeId
import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryById import eu.kanade.domain.animehistory.interactor.RemoveAnimeHistoryById
import eu.kanade.domain.animehistory.interactor.UpsertAnimeHistory import eu.kanade.domain.animehistory.interactor.UpsertAnimeHistory
import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository 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.GetAnimeSourcesWithFavoriteCount
import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithNonLibraryAnime import eu.kanade.domain.animesource.interactor.GetAnimeSourcesWithNonLibraryAnime
import eu.kanade.domain.animesource.interactor.GetEnabledAnimeSources import eu.kanade.domain.animesource.interactor.GetEnabledAnimeSources
import eu.kanade.domain.animesource.interactor.GetLanguagesWithAnimeSources 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.ToggleAnimeSource
import eu.kanade.domain.animesource.interactor.ToggleAnimeSourcePin 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.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.animetrack.interactor.DeleteAnimeTrack import eu.kanade.domain.animetrack.interactor.DeleteAnimeTrack
import eu.kanade.domain.animetrack.interactor.GetAnimeTracks 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.interactor.InsertAnimeTrack
import eu.kanade.domain.animetrack.repository.AnimeTrackRepository 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.CreateCategoryWithName
import eu.kanade.domain.category.interactor.DeleteAnimeCategory
import eu.kanade.domain.category.interactor.DeleteCategory 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.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.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.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.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.SetDisplayModeForAnimeCategory
import eu.kanade.domain.category.interactor.SetDisplayModeForCategory
import eu.kanade.domain.category.interactor.SetMangaCategories 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.SetSortModeForCategory
import eu.kanade.domain.category.interactor.UpdateAnimeCategory
import eu.kanade.domain.category.interactor.UpdateCategory 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.CategoryRepository
import eu.kanade.domain.category.repository.CategoryRepositoryAnime import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.domain.chapter.interactor.GetChapter 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.download.interactor.DeleteDownload
import eu.kanade.domain.episode.interactor.GetEpisode import eu.kanade.domain.episode.interactor.GetEpisode
import eu.kanade.domain.episode.interactor.GetEpisodeByAnimeId 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.SetSeenStatus
import eu.kanade.domain.episode.interactor.ShouldUpdateDbEpisode import eu.kanade.domain.episode.interactor.ShouldUpdateDbEpisode
import eu.kanade.domain.episode.interactor.SyncEpisodesWithSource 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.addFactory
import uy.kohesive.injekt.api.addSingletonFactory import uy.kohesive.injekt.api.addSingletonFactory
import uy.kohesive.injekt.api.get 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 import eu.kanade.domain.anime.interactor.ResetViewerFlags as ResetViewerFlagsAnime
class DomainModule : InjektModule { class DomainModule : InjektModule {
override fun InjektRegistrar.registerInjectables() { override fun InjektRegistrar.registerInjectables() {
addSingletonFactory<CategoryRepositoryAnime> { CategoryRepositoryImplAnime(get()) } addSingletonFactory<CategoryRepositoryAnime> { CategoryRepositoryImplAnime(get()) }
addFactory { GetCategoriesAnime(get()) } addFactory { GetAnimeCategories(get()) }
addFactory { ResetAnimeCategoryFlags(get(), get()) } addFactory { ResetAnimeCategoryFlags(get(), get()) }
addFactory { SetDisplayModeForAnimeCategory(get(), get()) } addFactory { SetDisplayModeForAnimeCategory(get(), get()) }
addFactory { SetSortModeForAnimeCategory(get(), get()) } addFactory { SetSortModeForAnimeCategory(get(), get()) }
@ -190,7 +197,7 @@ class DomainModule : InjektModule {
addSingletonFactory<AnimeTrackRepository> { AnimeTrackRepositoryImpl(get()) } addSingletonFactory<AnimeTrackRepository> { AnimeTrackRepositoryImpl(get()) }
addFactory { DeleteAnimeTrack(get()) } addFactory { DeleteAnimeTrack(get()) }
addFactory { GetAnimeTracksPerAnime(get()) addFactory { GetTracksPerAnime(get()) }
addFactory { GetAnimeTracks(get()) } addFactory { GetAnimeTracks(get()) }
addFactory { InsertAnimeTrack(get()) } addFactory { InsertAnimeTrack(get()) }
@ -200,15 +207,6 @@ class DomainModule : InjektModule {
addFactory { GetTracks(get()) } addFactory { GetTracks(get()) }
addFactory { InsertTrack(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()) } addSingletonFactory<EpisodeRepository> { EpisodeRepositoryImpl(get()) }
addFactory { GetEpisode(get()) } addFactory { GetEpisode(get()) }
addFactory { GetEpisodeByAnimeId(get()) } addFactory { GetEpisodeByAnimeId(get()) }
@ -218,6 +216,15 @@ class DomainModule : InjektModule {
addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) } addFactory { SyncEpisodesWithSource(get(), get(), get(), get()) }
addFactory { SyncEpisodesWithTrackServiceTwoWay(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()) } addSingletonFactory<AnimeHistoryRepository> { AnimeHistoryRepositoryImpl(get()) }
addFactory { DeleteAllAnimeHistory(get()) } addFactory { DeleteAllAnimeHistory(get()) }
addFactory { GetAnimeHistory(get()) } addFactory { GetAnimeHistory(get()) }
@ -257,8 +264,6 @@ class DomainModule : InjektModule {
addFactory { GetRemoteAnime(get()) } addFactory { GetRemoteAnime(get()) }
addFactory { GetAnimeSourcesWithFavoriteCount(get(), get()) } addFactory { GetAnimeSourcesWithFavoriteCount(get(), get()) }
addFactory { GetAnimeSourcesWithNonLibraryAnime(get()) } addFactory { GetAnimeSourcesWithNonLibraryAnime(get()) }
addFactory { SetAnimeMigrateSorting(get()) }
addFactory { ToggleAnimeLanguage(get()) }
addFactory { ToggleAnimeSource(get()) } addFactory { ToggleAnimeSource(get()) }
addFactory { ToggleAnimeSourcePin(get()) } addFactory { ToggleAnimeSourcePin(get()) }

View file

@ -23,7 +23,7 @@ class GetAnime(
return animeRepository.getAnimeByIdAsFlow(id) return animeRepository.getAnimeByIdAsFlow(id)
} }
suspend fun await(url: String, sourceId: Long): Anime? { fun subscribe(url: String, sourceId: Long): Flow<Anime?> {
return animeRepository.getAnimeByUrlAndSourceId(url, sourceId) 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 eu.kanade.domain.anime.repository.AnimeRepository
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class GetFavorites( class GetAnimeFavorites(
private val animeRepository: AnimeRepository, private val animeRepository: AnimeRepository,
) { ) {

View file

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

View file

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

View file

@ -1,5 +1,7 @@
package eu.kanade.domain.anime.model package eu.kanade.domain.anime.model
import eu.kanade.tachiyomi.source.model.UpdateStrategy
data class AnimeUpdate( data class AnimeUpdate(
val id: Long, val id: Long,
val source: Long? = null, val source: Long? = null,
@ -17,5 +19,6 @@ data class AnimeUpdate(
val genre: List<String>? = null, val genre: List<String>? = null,
val status: Long? = null, val status: Long? = null,
val thumbnailUrl: String? = null, val thumbnailUrl: String? = null,
val updateStrategy: UpdateStrategy? = null,
val initialized: Boolean? = 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.Anime
import eu.kanade.domain.anime.model.AnimeUpdate 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 import kotlinx.coroutines.flow.Flow
interface AnimeRepository { interface AnimeRepository {
@ -13,6 +13,8 @@ interface AnimeRepository {
suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime? suspend fun getAnimeByUrlAndSourceId(url: String, sourceId: Long): Anime?
fun getAnimeByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow<Anime?>
suspend fun getFavorites(): List<Anime> suspend fun getFavorites(): List<Anime>
suspend fun getAnimelibAnime(): List<AnimelibAnime> suspend fun getAnimelibAnime(): List<AnimelibAnime>
@ -31,5 +33,5 @@ interface AnimeRepository {
suspend fun update(update: AnimeUpdate): Boolean 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.anime.model.Anime
import eu.kanade.domain.episode.model.Episode import eu.kanade.domain.episode.model.Episode
import eu.kanade.domain.episode.model.toDbEpisode
import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.data.download.AnimeDownloadManager import eu.kanade.tachiyomi.data.animedownload.AnimeDownloadManager
import kotlinx.coroutines.NonCancellable import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
import kotlinx.coroutines.withContext
class DeleteAnimeDownload( class DeleteAnimeDownload(
private val sourceManager: AnimeSourceManager, private val sourceManager: AnimeSourceManager,
private val downloadManager: AnimeDownloadManager, 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 -> 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 package eu.kanade.domain.animeextension.interactor
import eu.kanade.core.util.asFlow import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager
import eu.kanade.tachiyomi.extension.AnimeExtensionManager
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
class GetAnimeExtensionLanguages( class GetAnimeExtensionLanguages(
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
private val extensionManager: AnimeExtensionManager, private val extensionManager: AnimeExtensionManager,
) { ) {
fun subscribe(): Flow<List<String>> { fun subscribe(): Flow<List<String>> {
return combine( return combine(
preferences.enabledLanguages().asFlow(), preferences.enabledLanguages().changes(),
extensionManager.getAvailableExtensionsObservable().asFlow(), extensionManager.availableExtensionsFlow,
) { enabledLanguage, availableExtensions -> ) { enabledLanguage, availableExtensions ->
availableExtensions availableExtensions
.map { it.lang } .map { it.lang }

View file

@ -1,14 +1,14 @@
package eu.kanade.domain.animeextension.interactor 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.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 eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
class GetAnimeExtensionSources( class GetAnimeExtensionSources(
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
) { ) {
fun subscribe(extension: AnimeExtension.Installed): Flow<List<AnimeExtensionSourceItem>> { fun subscribe(extension: AnimeExtension.Installed): Flow<List<AnimeExtensionSourceItem>> {
@ -16,7 +16,7 @@ class GetAnimeExtensionSources(
val isMultiLangSingleSource = val isMultiLangSingleSource =
isMultiSource && extension.sources.map { it.name }.distinct().size == 1 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 fun AnimeSource.isEnabled() = id.toString() !in disabledSources
extension.sources 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 package eu.kanade.domain.animeextension.interactor
import eu.kanade.core.util.asFlow import eu.kanade.domain.animeextension.model.AnimeExtensions
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.AnimeExtensionManager import eu.kanade.tachiyomi.animeextension.AnimeExtensionManager
import eu.kanade.tachiyomi.extension.model.AnimeExtension import eu.kanade.tachiyomi.animeextension.model.AnimeExtension
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
typealias ExtensionSegregation = Triple<List<AnimeExtension.Installed>, List<AnimeExtension.Untrusted>, List<AnimeExtension.Available>> class GetAnimeExtensionsByType(
private val preferences: SourcePreferences,
class GetAnimeExtensions(
private val preferences: PreferencesHelper,
private val extensionManager: AnimeExtensionManager, private val extensionManager: AnimeExtensionManager,
) { ) {
fun subscribe(): Flow<ExtensionSegregation> { fun subscribe(): Flow<AnimeExtensions> {
val showNsfwSources = preferences.showNsfwSource().get() val showNsfwSources = preferences.showNsfwSource().get()
return combine( return combine(
preferences.enabledLanguages().asFlow(), preferences.enabledLanguages().changes(),
extensionManager.getInstalledExtensionsObservable().asFlow(), extensionManager.installedExtensionsFlow,
extensionManager.getUntrustedExtensionsObservable().asFlow(), extensionManager.untrustedExtensionsFlow,
extensionManager.getAvailableExtensionsObservable().asFlow(), extensionManager.availableExtensionsFlow,
) { _activeLanguages, _installed, _untrusted, _available -> ) { _activeLanguages, _installed, _untrusted, _available ->
val (updates, installed) = _installed
val installed = _installed .filter { (showNsfwSources || it.isNsfw.not()) }
.filter { it.hasUpdate.not() && (showNsfwSources || it.isNsfw.not()) }
.sortedWith( .sortedWith(
compareBy<AnimeExtension.Installed> { it.isObsolete.not() } compareBy<AnimeExtension.Installed> { it.isObsolete.not() }
.thenBy(String.CASE_INSENSITIVE_ORDER) { it.name }, .thenBy(String.CASE_INSENSITIVE_ORDER) { it.name },
) )
.partition { it.hasUpdate }
val untrusted = _untrusted val untrusted = _untrusted
.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
@ -38,11 +36,11 @@ class GetAnimeExtensions(
.filter { extension -> .filter { extension ->
_installed.none { it.pkgName == extension.pkgName } && _installed.none { it.pkgName == extension.pkgName } &&
_untrusted.none { it.pkgName == extension.pkgName } && _untrusted.none { it.pkgName == extension.pkgName } &&
extension.lang in _activeLanguages &&
(showNsfwSources || extension.isNsfw.not()) (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 import eu.kanade.domain.animehistory.repository.AnimeHistoryRepository
class DeleteAnimeHistoryTable( class DeleteAllAnimeHistory(
private val repository: AnimeHistoryRepository, private val repository: AnimeHistoryRepository,
) { ) {

View file

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

View file

@ -1,18 +1,51 @@
package eu.kanade.domain.animehistory.interactor 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.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.domain.episode.model.Episode
import eu.kanade.tachiyomi.util.episode.getEpisodeSort
class GetNextEpisode( 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? { suspend fun await(): Episode? {
return repository.getNextEpisode(animeId, episodeId) val history = historyRepository.getLastHistory() ?: return null
return await(history.animeId, history.episodeId)
} }
suspend fun await(): Episode? { suspend fun await(animeId: Long, episodeId: Long): Episode? {
val history = repository.getLastHistory() ?: return null val episode = getEpisode.await(episodeId) ?: return null
return repository.getNextEpisode(history.animeId, history.episodeId) 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 package eu.kanade.domain.animehistory.repository
import androidx.paging.PagingSource
import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate import eu.kanade.domain.animehistory.model.AnimeHistoryUpdate
import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations import eu.kanade.domain.animehistory.model.AnimeHistoryWithRelations
import eu.kanade.domain.episode.model.Episode import kotlinx.coroutines.flow.Flow
interface AnimeHistoryRepository { interface AnimeHistoryRepository {
fun getHistory(query: String): PagingSource<Long, AnimeHistoryWithRelations> fun getHistory(query: String): Flow<List<AnimeHistoryWithRelations>>
suspend fun getLastHistory(): AnimeHistoryWithRelations? suspend fun getLastHistory(): AnimeHistoryWithRelations?
suspend fun getNextEpisode(animeId: Long, episodeId: Long): Episode?
suspend fun resetHistory(historyId: Long) suspend fun resetHistory(historyId: Long)
suspend fun resetHistoryByAnimeId(animeId: 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.model.AnimeSource
import eu.kanade.domain.animesource.repository.AnimeSourceRepository import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.source.interactor.SetMigrateSorting 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.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import java.text.Collator import java.text.Collator
@ -12,13 +12,13 @@ import java.util.Locale
class GetAnimeSourcesWithFavoriteCount( class GetAnimeSourcesWithFavoriteCount(
private val repository: AnimeSourceRepository, private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
) { ) {
fun subscribe(): Flow<List<Pair<AnimeSource, Long>>> { fun subscribe(): Flow<List<Pair<AnimeSource, Long>>> {
return combine( return combine(
preferences.migrationSortingDirection().asFlow(), preferences.migrationSortingDirection().changes(),
preferences.migrationSortingMode().asFlow(), preferences.migrationSortingMode().changes(),
repository.getSourcesWithFavoriteCount(), repository.getSourcesWithFavoriteCount(),
) { direction, mode, list -> ) { direction, mode, list ->
list.sortedWith(sortFn(direction, mode)) list.sortedWith(sortFn(direction, mode))

View file

@ -1,14 +1,14 @@
package eu.kanade.domain.animesource.interactor package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSourceWithCount
import eu.kanade.domain.animesource.repository.AnimeSourceRepository import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.tachiyomi.animesource.AnimeSource
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class GetAnimeSourcesWithNonLibraryAnime( class GetAnimeSourcesWithNonLibraryAnime(
private val repository: AnimeSourceRepository, private val repository: AnimeSourceRepository,
) { ) {
fun subscribe(): Flow<List<Pair<AnimeSource, Long>>> { fun subscribe(): Flow<List<AnimeSourceWithCount>> {
return repository.getSourcesWithNonLibraryAnime() 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.Pin
import eu.kanade.domain.animesource.model.Pins import eu.kanade.domain.animesource.model.Pins
import eu.kanade.domain.animesource.repository.AnimeSourceRepository import eu.kanade.domain.animesource.repository.AnimeSourceRepository
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.animesource.LocalAnimeSource import eu.kanade.tachiyomi.animesource.LocalAnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
class GetEnabledAnimeSources( class GetEnabledAnimeSources(
private val repository: AnimeSourceRepository, private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
) { ) {
fun subscribe(): Flow<List<AnimeSource>> { fun subscribe(): Flow<List<AnimeSource>> {
return combine( return combine(
preferences.pinnedAnimeSources().asFlow(), preferences.pinnedAnimeSources().changes(),
preferences.enabledLanguages().asFlow(), preferences.enabledLanguages().changes(),
preferences.disabledAnimeSources().asFlow(), preferences.disabledAnimeSources().changes(),
preferences.lastUsedAnimeSource().asFlow(), preferences.lastUsedAnimeSource().changes(),
repository.getSources(), repository.getSources(),
) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, sources -> ) { pinnedSourceIds, enabledLanguages, disabledSources, lastUsedSource, sources ->
val duplicatePins = preferences.duplicatePinnedSources().get() 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.model.AnimeSource
import eu.kanade.domain.animesource.repository.AnimeSourceRepository 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 eu.kanade.tachiyomi.util.system.LocaleHelper
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
class GetLanguagesWithAnimeSources( class GetLanguagesWithAnimeSources(
private val repository: AnimeSourceRepository, private val repository: AnimeSourceRepository,
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
) { ) {
fun subscribe(): Flow<Map<String, List<AnimeSource>>> { fun subscribe(): Flow<Map<String, List<AnimeSource>>> {
return combine( return combine(
preferences.enabledLanguages().asFlow(), preferences.enabledLanguages().changes(),
preferences.disabledSources().asFlow(), preferences.disabledSources().changes(),
repository.getOnlineSources(), repository.getOnlineSources(),
) { enabledLanguage, disabledSource, onlineSources -> ) { enabledLanguage, disabledSource, onlineSources ->
val sortedSources = onlineSources.sortedWith( 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 package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.core.preference.getAndSet
import eu.kanade.tachiyomi.util.preference.plusAssign
class ToggleAnimeSource( 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) await(source.id, enable)
} }
fun await(sourceId: Long, enable: Boolean = sourceId.toString() in preferences.disabledAnimeSources().get()) { fun await(sourceId: Long, enable: Boolean = isEnabled(sourceId)) {
if (enable) { preferences.disabledAnimeSources().getAndSet { disabled ->
preferences.disabledAnimeSources() -= sourceId.toString() if (enable) disabled.minus("$sourceId") else disabled.plus("$sourceId")
} else {
preferences.disabledAnimeSources() += sourceId.toString()
} }
} }
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 package eu.kanade.domain.animesource.interactor
import eu.kanade.domain.animesource.model.AnimeSource import eu.kanade.domain.animesource.model.AnimeSource
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.preference.minusAssign import eu.kanade.tachiyomi.core.preference.getAndSet
import eu.kanade.tachiyomi.util.preference.plusAssign
class ToggleAnimeSourcePin( class ToggleAnimeSourcePin(
private val preferences: PreferencesHelper, private val preferences: SourcePreferences,
) { ) {
fun await(source: AnimeSource) { fun await(source: AnimeSource) {
val isPinned = source.id.toString() in preferences.pinnedAnimeSources().get() val isPinned = source.id.toString() in preferences.pinnedAnimeSources().get()
if (isPinned) { preferences.pinnedAnimeSources().getAndSet { pinned ->
preferences.pinnedAnimeSources() -= source.id.toString() if (isPinned) pinned.minus("${source.id}") else pinned.plus("${source.id}")
} else {
preferences.pinnedAnimeSources() += source.id.toString()
} }
} }
} }

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 id: Long,
val lang: String, val lang: String,
val name: 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.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -17,8 +17,11 @@ data class AnimeSource(
val isUsedLast: Boolean = false, val isUsedLast: Boolean = false,
) { ) {
val nameWithLanguage: String val visualName: String
get() = "$name (${lang.uppercase()})" get() = when {
lang.isEmpty() -> name
else -> "$name (${lang.uppercase()})"
}
val icon: ImageBitmap? val icon: ImageBitmap?
get() { 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 package eu.kanade.domain.animesource.repository
import eu.kanade.domain.animesource.model.AnimeSource 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 kotlinx.coroutines.flow.Flow
import eu.kanade.tachiyomi.animesource.AnimeSource as LoadedAnimeSource
interface AnimeSourceRepository { interface AnimeSourceRepository {
@ -13,9 +14,11 @@ interface AnimeSourceRepository {
fun getSourcesWithFavoriteCount(): Flow<List<Pair<AnimeSource, Long>>> 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>> { fun subscribe(animeId: Long): Flow<List<AnimeTrack>> {
return animetrackRepository.getAnimeTracksByAnimeIdAsFlow(animeId) 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.preference.PreferenceStore
import eu.kanade.tachiyomi.core.provider.FolderProvider 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( class BackupPreferences(
private val folderProvider: FolderProvider, private val folderProvider: FolderProvider,
@ -13,4 +17,6 @@ class BackupPreferences(
fun numberOfBackups() = preferenceStore.getInt("backup_slots", 2) fun numberOfBackups() = preferenceStore.getInt("backup_slots", 2)
fun backupInterval() = preferenceStore.getInt("backup_interval", 12) 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.core.preference.getEnum
import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
import eu.kanade.tachiyomi.util.system.isPreviewBuildType
import eu.kanade.tachiyomi.util.system.isReleaseBuildType
class BasePreferences( class BasePreferences(
val context: Context, val context: Context,
@ -26,5 +24,6 @@ class BasePreferences(
if (DeviceUtil.isMiui) PreferenceValues.ExtensionInstaller.LEGACY else PreferenceValues.ExtensionInstaller.PACKAGEINSTALLER, 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 eu.kanade.domain.category.repository.CategoryRepositoryAnime
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class GetCategoriesAnime( class GetAnimeCategories(
private val categoryRepository: CategoryRepositoryAnime, 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.model.CategoryUpdate
import eu.kanade.domain.category.repository.CategoryRepositoryAnime import eu.kanade.domain.category.repository.CategoryRepositoryAnime
import eu.kanade.tachiyomi.util.lang.withNonCancellableContext
class UpdateCategoryAnime( class UpdateAnimeCategory(
private val categoryRepository: CategoryRepositoryAnime, private val categoryRepository: CategoryRepositoryAnime,
) { ) {
suspend fun await(payload: CategoryUpdate): Result { suspend fun await(payload: CategoryUpdate): Result = withNonCancellableContext {
return try { try {
categoryRepository.update(payload) categoryRepository.updatePartial(payload)
Result.Success Result.Success
} catch (e: Exception) { } catch (e: Exception) {
Result.Error(e) Result.Error(e)

View file

@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.Flow
interface CategoryRepositoryAnime { interface CategoryRepositoryAnime {
suspend fun get(id: Long): Category?
suspend fun getAll(): List<Category> suspend fun getAll(): List<Category>
fun getAllAsFlow(): Flow<List<Category>> fun getAllAsFlow(): Flow<List<Category>>
@ -14,13 +16,13 @@ interface CategoryRepositoryAnime {
fun getCategoriesByAnimeIdAsFlow(animeId: Long): Flow<List<Category>> fun getCategoriesByAnimeIdAsFlow(animeId: Long): Flow<List<Category>>
@Throws(DuplicateNameException::class) suspend fun insert(category: Category)
suspend fun insert(name: String, order: Long)
@Throws(DuplicateNameException::class) suspend fun updatePartial(update: CategoryUpdate)
suspend fun update(payload: CategoryUpdate)
suspend fun updatePartial(updates: List<CategoryUpdate>)
suspend fun updateAllFlags(flags: Long?)
suspend fun delete(categoryId: 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 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 saveChaptersAsCBZ() = preferenceStore.getBoolean("save_chapter_as_cbz", true)
fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", false) fun splitTallImages() = preferenceStore.getBoolean("split_tall_images", false)
fun autoDownloadWhileReading() = preferenceStore.getInt("auto_download_while_reading", 0) 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 removeAfterReadSlots() = preferenceStore.getInt("remove_after_read_slots", -1)
fun removeAfterMarkedAsRead() = preferenceStore.getBoolean("pref_remove_after_marked_as_read_key", false) 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 removeBookmarkedChapters() = preferenceStore.getBoolean("pref_remove_bookmarked", false)
fun removeExcludeCategories() = preferenceStore.getStringSet("remove_exclude_categories", emptySet()) 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 downloadNewChapters() = preferenceStore.getBoolean("download_new", false)
fun downloadNewEpisodes() = preferenceStore.getBoolean("download_new_episode", false)
fun downloadNewChapterCategories() = preferenceStore.getStringSet("download_new_categories", emptySet()) 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 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.model.Anime
import eu.kanade.domain.anime.repository.AnimeRepository import eu.kanade.domain.anime.repository.AnimeRepository
import eu.kanade.domain.animedownload.interactor.DeleteAnimeDownload 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.Episode
import eu.kanade.domain.episode.model.EpisodeUpdate import eu.kanade.domain.episode.model.EpisodeUpdate
import eu.kanade.domain.episode.repository.EpisodeRepository 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 eu.kanade.tachiyomi.util.system.logcat
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import logcat.LogPriority import logcat.LogPriority
class SetSeenStatus( class SetSeenStatus(
private val preferences: PreferencesHelper, private val downloadPreferences: DownloadPreferences,
private val deleteDownload: DeleteAnimeDownload, private val deleteDownload: DeleteAnimeDownload,
private val animeRepository: AnimeRepository, private val animeRepository: AnimeRepository,
private val episodeRepository: EpisodeRepository, private val episodeRepository: EpisodeRepository,
@ -27,38 +26,33 @@ class SetSeenStatus(
) )
} }
suspend fun await(seen: Boolean, vararg values: Episode): Result = withContext(NonCancellable) f@{ suspend fun await(seen: Boolean, vararg episodes: Episode): Result = withNonCancellableContext {
val episodes = values.filterNot { it.seen == seen } val episodesToUpdate = episodes.filter {
when (seen) {
if (episodes.isEmpty()) { true -> !it.seen
return@f Result.NoEpisodes 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 { try {
episodeRepository.updateAll( episodeRepository.updateAll(
episodes.map { episode -> episodesToUpdate.map { mapper(it, seen) },
mapper(episode, seen)
},
) )
} catch (e: Exception) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) logcat(LogPriority.ERROR, e)
return@f Result.InternalError(e) return@withNonCancellableContext Result.InternalError(e)
} }
if (seen && preferences.removeAfterMarkedAsRead()) { if (seen && downloadPreferences.removeAfterMarkedAsRead().get()) {
anime.forEach { anime -> episodesToUpdate
.groupBy { it.animeId }
.forEach { (animeId, episodes) ->
deleteDownload.awaitAll( deleteDownload.awaitAll(
anime = anime, anime = animeRepository.getAnimeById(animeId),
values = episodes episodes = episodes.toTypedArray(),
.filter { anime.id == it.animeId }
.toTypedArray(),
) )
} }
} }
@ -66,10 +60,10 @@ class SetSeenStatus(
Result.Success Result.Success
} }
suspend fun await(animeId: Long, seen: Boolean): Result = withContext(NonCancellable) f@{ suspend fun await(animeId: Long, seen: Boolean): Result = withNonCancellableContext {
return@f await( await(
seen = seen, seen = seen,
values = episodeRepository episodes = episodeRepository
.getEpisodeByAnimeId(animeId) .getEpisodeByAnimeId(animeId)
.toTypedArray(), .toTypedArray(),
) )

View file

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

View file

@ -38,7 +38,7 @@ data class Episode(
url = sEpisode.url, url = sEpisode.url,
dateUpload = sEpisode.date_upload, dateUpload = sEpisode.date_upload,
episodeNumber = sEpisode.episode_number, 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 getEpisodeByAnimeId(animeId: Long): List<Episode>
suspend fun getBookmarkedEpisodesByAnimeId(animeId: Long): List<Episode>
suspend fun getEpisodeById(id: Long): Episode? suspend fun getEpisodeById(id: Long): Episode?
fun getEpisodeByAnimeIdAsFlow(animeId: Long): Flow<List<Episode>> fun getEpisodeByAnimeIdAsFlow(animeId: Long): Flow<List<Episode>>

View file

@ -1,5 +1,6 @@
package eu.kanade.domain.library.service 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.LibraryDisplayMode
import eu.kanade.domain.library.model.LibrarySort import eu.kanade.domain.library.model.LibrarySort
import eu.kanade.domain.manga.model.Manga import eu.kanade.domain.manga.model.Manga
@ -13,6 +14,7 @@ import eu.kanade.tachiyomi.widget.ExtendedNavigationView
class LibraryPreferences( class LibraryPreferences(
private val preferenceStore: PreferenceStore, 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) 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 showUpdatesNavBadge() = preferenceStore.getBoolean("library_update_show_tab_badge", false)
fun unreadUpdatesCount() = preferenceStore.getInt("library_unread_updates_count", 0) fun unreadUpdatesCount() = preferenceStore.getInt("library_unread_updates_count", 0)
fun unseenUpdatesCount() = preferenceStore.getInt("library_unseen_updates_count", 0)
// endregion // endregion
// region Category // region Category
fun defaultCategory() = preferenceStore.getInt("default_category", -1) fun defaultCategory() = preferenceStore.getInt("default_category", -1)
fun defaultAnimeCategory() = preferenceStore.getInt("default_anime_category", -1)
fun lastUsedCategory() = preferenceStore.getInt("last_used_category", 0) 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 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 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 categorizedDisplaySettings() = preferenceStore.getBoolean("categorized_display", false)
fun libraryUpdateCategories() = preferenceStore.getStringSet("library_update_categories", emptySet()) 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 libraryUpdateCategoriesExclude() = preferenceStore.getStringSet("library_update_categories_exclude", emptySet())
fun animelibUpdateCategoriesExclude() = preferenceStore.getStringSet("animelib_update_categories_exclude", emptySet())
// endregion // endregion
// region Chapter // region Chapter
fun filterChapterByRead() = preferenceStore.getLong("default_chapter_filter_by_read", Manga.SHOW_ALL) 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 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 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 // and upload date
fun sortChapterBySourceOrNumber() = preferenceStore.getLong("default_chapter_sort_by_source_or_number", Manga.CHAPTER_SORTING_SOURCE) 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 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 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) { fun setChapterSettingsDefault(manga: Manga) {
filterChapterByRead().set(manga.unreadFilterRaw) filterChapterByRead().set(manga.unreadFilterRaw)
@ -105,6 +120,15 @@ class LibraryPreferences(
sortChapterByAscendingOrDescending().set(if (manga.sortDescending()) Manga.CHAPTER_SORT_DESC else Manga.CHAPTER_SORT_ASC) 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) fun autoClearChapterCache() = preferenceStore.getBoolean("auto_clear_chapter_cache", false)
// endregion // endregion

View file

@ -16,12 +16,18 @@ class SourcePreferences(
fun disabledSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet()) fun disabledSources() = preferenceStore.getStringSet("hidden_catalogues", emptySet())
fun disabledAnimeSources() = preferenceStore.getStringSet("hidden_anime_catalogues", emptySet())
fun pinnedSources() = preferenceStore.getStringSet("pinned_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 duplicatePinnedSources() = preferenceStore.getBoolean("duplicate_pinned_sources", false)
fun lastUsedSource() = preferenceStore.getLong("last_catalogue_source", -1) 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 showNsfwSource() = preferenceStore.getBoolean("show_nsfw_source", true)
fun migrationSortingMode() = preferenceStore.getEnum("pref_migration_sorting", SetMigrateSorting.Mode.ALPHABETICAL) 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 extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
fun animeextensionUpdatesCount() = preferenceStore.getInt("animeext_updates_count", 0)
fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet()) fun trustedSignatures() = preferenceStore.getStringSet("trusted_signatures", emptySet())
fun searchPinnedSourcesOnly() = preferenceStore.getBoolean("search_pinned_sources_only", false) 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.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues 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.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -36,72 +30,54 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text 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.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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource 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.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.AnimeEpisodeListItem
import eu.kanade.presentation.anime.components.AnimeInfoHeader import eu.kanade.presentation.anime.components.AnimeInfoBox
import eu.kanade.presentation.anime.components.AnimeSmallAppBar
import eu.kanade.presentation.anime.components.AnimeTopAppBar
import eu.kanade.presentation.anime.components.EpisodeHeader 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.ExtendedFloatingActionButton
import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.Scaffold 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.components.VerticalFastScroller
import eu.kanade.presentation.manga.DownloadAction import eu.kanade.presentation.manga.DownloadAction
import eu.kanade.presentation.manga.EpisodeDownloadAction import eu.kanade.presentation.manga.MangaScreenItem
import eu.kanade.presentation.util.ExitUntilCollapsedScrollBehavior import eu.kanade.presentation.manga.components.MangaToolbar
import eu.kanade.presentation.util.isScrolledToEnd import eu.kanade.presentation.util.isScrolledToEnd
import eu.kanade.presentation.util.isScrollingUp import eu.kanade.presentation.util.isScrollingUp
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.AnimeSourceManager import eu.kanade.tachiyomi.animesource.AnimeSourceManager
import eu.kanade.tachiyomi.animesource.getNameForAnimeInfo import eu.kanade.tachiyomi.animesource.getNameForAnimeInfo
import eu.kanade.tachiyomi.data.download.model.AnimeDownload import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.anime.AnimeScreenState import eu.kanade.tachiyomi.ui.anime.AnimeScreenState
import eu.kanade.tachiyomi.ui.anime.EpisodeItem import eu.kanade.tachiyomi.ui.anime.EpisodeItem
import eu.kanade.tachiyomi.util.lang.toRelativeString import eu.kanade.tachiyomi.ui.player.setting.PlayerPreferences
import kotlinx.coroutines.runBlocking
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get 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 @Composable
fun AnimeScreen( fun AnimeScreen(
state: AnimeScreenState.Success, state: AnimeScreenState.Success,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
windowWidthSizeClass: WindowWidthSizeClass, isTabletUi: Boolean,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit,
onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?, onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?,
@ -130,8 +106,12 @@ fun AnimeScreen(
onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<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( AnimeScreenSmallImpl(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
@ -142,9 +122,9 @@ fun AnimeScreen(
onWebViewClicked = onWebViewClicked, onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked, onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked, onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked, onFilterClicked = onFilterButtonClicked,
onRefresh = onRefresh, onRefresh = onRefresh,
onContinueReading = onContinueWatching, onContinueWatching = onContinueWatching,
onSearch = onSearch, onSearch = onSearch,
onCoverClicked = onCoverClicked, onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked, onShareClicked = onShareClicked,
@ -156,11 +136,13 @@ fun AnimeScreen(
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked,
onMultiDeleteClicked = onMultiDeleteClicked, onMultiDeleteClicked = onMultiDeleteClicked,
onEpisodeSelected = onEpisodeSelected,
onAllEpisodeSelected = onAllEpisodeSelected,
onInvertSelection = onInvertSelection,
) )
} else { } else {
AnimeScreenLargeImpl( AnimeScreenLargeImpl(
state = state, state = state,
windowWidthSizeClass = windowWidthSizeClass,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
onBackClicked = onBackClicked, onBackClicked = onBackClicked,
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
@ -171,7 +153,7 @@ fun AnimeScreen(
onTagClicked = onTagClicked, onTagClicked = onTagClicked,
onFilterButtonClicked = onFilterButtonClicked, onFilterButtonClicked = onFilterButtonClicked,
onRefresh = onRefresh, onRefresh = onRefresh,
onContinueReading = onContinueWatching, onContinueWatching = onContinueWatching,
onSearch = onSearch, onSearch = onSearch,
onCoverClicked = onCoverClicked, onCoverClicked = onCoverClicked,
onShareClicked = onShareClicked, onShareClicked = onShareClicked,
@ -183,6 +165,9 @@ fun AnimeScreen(
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked,
onMultiDeleteClicked = onMultiDeleteClicked, onMultiDeleteClicked = onMultiDeleteClicked,
onEpisodeSelected = onEpisodeSelected,
onAllEpisodeSelected = onAllEpisodeSelected,
onInvertSelection = onInvertSelection,
) )
} }
} }
@ -198,9 +183,9 @@ private fun AnimeScreenSmallImpl(
onWebViewClicked: (() -> Unit)?, onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?, onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit, onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit, onFilterClicked: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onContinueReading: () -> Unit, onContinueWatching: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit, onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog // For cover dialog
@ -215,121 +200,64 @@ private fun AnimeScreenSmallImpl(
// For bottom action menu // For bottom action menu
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit, onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsRead: Boolean) -> Unit, onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<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() 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 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 = { val internalOnBackPressed = {
if (selected.isNotEmpty()) { if (episodes.any { it.selected }) {
selected.clear() onAllEpisodeSelected(false)
} else { } else {
onBackClicked() onBackClicked()
} }
} }
BackHandler(onBack = internalOnBackPressed) BackHandler(onBack = internalOnBackPressed)
Scaffold( Scaffold(
modifier = Modifier modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection) .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal).asPaddingValues()),
.padding(insetPadding),
topBar = { topBar = {
AnimeTopAppBar( val firstVisibleItemIndex by remember {
modifier = Modifier derivedStateOf { episodeListState.firstVisibleItemIndex }
.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
} }
consumed val firstVisibleItemScrollOffset by remember {
}, derivedStateOf { episodeListState.firstVisibleItemScrollOffset }
orientation = Orientation.Vertical, }
interactionSource = episodeListState.interactionSource as MutableInteractionSource, 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, title = state.anime.title,
author = state.anime.author, titleAlphaProvider = { animatedTitleAlpha },
artist = state.anime.artist, backgroundAlphaProvider = { animatedBgAlpha },
description = state.anime.description, hasFilters = state.anime.episodesFiltered(),
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(),
incognitoMode = state.isIncognitoMode, incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode, downloadedOnlyMode = state.isDownloadedOnlyMode,
fromSource = state.isFromSource,
onBackClicked = internalOnBackPressed, onBackClicked = internalOnBackPressed,
onCoverClick = onCoverClicked, onClickFilter = onFilterClicked,
onTagClicked = onTagClicked, onClickShare = onShareClicked,
onAddToLibraryClicked = onAddToLibraryClicked, onClickDownload = onDownloadActionClicked,
onWebViewClicked = onWebViewClicked, onClickEditCategory = onEditCategoryClicked,
onTrackingClicked = onTrackingClicked, onClickMigrate = onMigrateClicked,
onFilterButtonClicked = onFilterButtonClicked, actionModeCounter = episodes.count { it.selected },
onShareClicked = onShareClicked, onSelectAll = { onAllEpisodeSelected(true) },
onDownloadClicked = onDownloadActionClicked, onInvertSelection = { onInvertSelection() },
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,
) )
}, },
bottomBar = { bottomBar = {
SharedAnimeBottomActionMenu( SharedAnimeBottomActionMenu(
selected = selected, selected = episodes.filter { it.selected },
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -342,7 +270,7 @@ private fun AnimeScreenSmallImpl(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( AnimatedVisibility(
visible = episodes.any { !it.episode.seen } && selected.isEmpty(), visible = episodes.any { !it.episode.seen } && episodes.none { it.selected },
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
@ -355,35 +283,102 @@ private fun AnimeScreenSmallImpl(
} }
Text(text = stringResource(id)) Text(text = stringResource(id))
}, },
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) }, icon = {
onClick = onContinueReading, Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
)
},
onClick = onContinueWatching,
expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(), expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(),
modifier = Modifier modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()), .padding(
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom)
.asPaddingValues(),
),
) )
} }
}, },
) { contentPadding -> ) { contentPadding ->
val withNavBarContentPadding = contentPadding + val topPadding = contentPadding.calculateTopPadding()
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()
SwipeRefresh(
refreshing = state.isRefreshingData,
onRefresh = onRefresh,
enabled = episodes.none { it.selected },
indicatorPadding = contentPadding,
) {
VerticalFastScroller( VerticalFastScroller(
listState = episodeListState, listState = episodeListState,
thumbAllowed = { scrollBehavior.state.offset == scrollBehavior.state.offsetLimit }, topContentPadding = topPadding,
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(LocalLayoutDirection.current),
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
state = episodeListState, 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( sharedEpisodeItems(
episodes = episodes, episodes = episodes,
state = state,
selected = selected,
selectedPositions = selectedPositions,
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
onDownloadEpisode = onDownloadEpisode, onDownloadEpisode = onDownloadEpisode,
onEpisodeSelected = onEpisodeSelected,
) )
} }
} }
@ -394,7 +389,6 @@ private fun AnimeScreenSmallImpl(
@Composable @Composable
fun AnimeScreenLargeImpl( fun AnimeScreenLargeImpl(
state: AnimeScreenState.Success, state: AnimeScreenState.Success,
windowWidthSizeClass: WindowWidthSizeClass,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
onBackClicked: () -> Unit, onBackClicked: () -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit,
@ -405,7 +399,7 @@ fun AnimeScreenLargeImpl(
onTagClicked: (String) -> Unit, onTagClicked: (String) -> Unit,
onFilterButtonClicked: () -> Unit, onFilterButtonClicked: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onContinueReading: () -> Unit, onContinueWatching: () -> Unit,
onSearch: (query: String, global: Boolean) -> Unit, onSearch: (query: String, global: Boolean) -> Unit,
// For cover dialog // For cover dialog
@ -420,40 +414,37 @@ fun AnimeScreenLargeImpl(
// For bottom action menu // For bottom action menu
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit, onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsRead: Boolean) -> Unit, onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit,
onMultiDeleteClicked: (List<Episode>) -> Unit, onMultiDeleteClicked: (List<Episode>) -> Unit,
// Episode selection
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
onAllEpisodeSelected: (Boolean) -> Unit,
onInvertSelection: () -> Unit,
) { ) {
val layoutDirection = LocalLayoutDirection.current val layoutDirection = LocalLayoutDirection.current
val density = LocalDensity.current val density = LocalDensity.current
val episodes = remember(state) { state.processedEpisodes.toList() }
val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues()
val (topBarHeight, onTopBarHeightChanged) = remember { mutableStateOf(0) } var topBarHeight by remember { mutableStateOf(0) }
SwipeRefresh( SwipeRefresh(
state = rememberSwipeRefreshState(state.isRefreshingInfo || state.isRefreshingEpisode), refreshing = state.isRefreshingData,
onRefresh = onRefresh, onRefresh = onRefresh,
enabled = episodes.none { it.selected },
indicatorPadding = PaddingValues( indicatorPadding = PaddingValues(
start = insetPadding.calculateStartPadding(layoutDirection), start = insetPadding.calculateStartPadding(layoutDirection),
top = with(density) { topBarHeight.toDp() }, top = with(density) { topBarHeight.toDp() },
end = insetPadding.calculateEndPadding(layoutDirection), end = insetPadding.calculateEndPadding(layoutDirection),
), ),
clipIndicatorToPadding = true,
indicator = { s, trigger ->
SwipeRefreshIndicator(
state = s,
refreshTriggerDistance = trigger,
)
},
) { ) {
val episodeListState = rememberLazyListState() 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 = { val internalOnBackPressed = {
if (selected.isNotEmpty()) { if (episodes.any { it.selected }) {
selected.clear() onAllEpisodeSelected(false)
} else { } else {
onBackClicked() onBackClicked()
} }
@ -463,29 +454,23 @@ fun AnimeScreenLargeImpl(
Scaffold( Scaffold(
modifier = Modifier.padding(insetPadding), modifier = Modifier.padding(insetPadding),
topBar = { topBar = {
AnimeSmallAppBar( MangaToolbar(
modifier = Modifier.onSizeChanged { onTopBarHeightChanged(it.height) }, modifier = Modifier.onSizeChanged { topBarHeight = (it.height) },
title = state.anime.title, title = state.anime.title,
titleAlphaProvider = { if (selected.isEmpty()) 0f else 1f }, titleAlphaProvider = { if (episodes.any { it.selected }) 1f else 0f },
backgroundAlphaProvider = { 1f }, backgroundAlphaProvider = { 1f },
hasFilters = state.anime.episodesFiltered(),
incognitoMode = state.isIncognitoMode, incognitoMode = state.isIncognitoMode,
downloadedOnlyMode = state.isDownloadedOnlyMode, downloadedOnlyMode = state.isDownloadedOnlyMode,
onBackClicked = internalOnBackPressed, onBackClicked = internalOnBackPressed,
onShareClicked = onShareClicked, onClickFilter = onFilterButtonClicked,
onDownloadClicked = onDownloadActionClicked, onClickShare = onShareClicked,
onEditCategoryClicked = onEditCategoryClicked, onClickDownload = onDownloadActionClicked,
onMigrateClicked = onMigrateClicked, onClickEditCategory = onEditCategoryClicked,
changeAnimeSkipIntro = changeAnimeSkipIntro, onClickMigrate = onMigrateClicked,
actionModeCounter = selected.size, actionModeCounter = episodes.count { it.selected },
onSelectAll = { onSelectAll = { onAllEpisodeSelected(true) },
selected.clear() onInvertSelection = { onInvertSelection() },
selected.addAll(episodes)
},
onInvertSelection = {
val toSelect = episodes - selected
selected.clear()
selected.addAll(toSelect)
},
) )
}, },
bottomBar = { bottomBar = {
@ -494,7 +479,7 @@ fun AnimeScreenLargeImpl(
contentAlignment = Alignment.BottomEnd, contentAlignment = Alignment.BottomEnd,
) { ) {
SharedAnimeBottomActionMenu( SharedAnimeBottomActionMenu(
selected = selected, selected = episodes.filter { it.selected },
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiBookmarkClicked = onMultiBookmarkClicked,
onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked,
@ -508,7 +493,7 @@ fun AnimeScreenLargeImpl(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( AnimatedVisibility(
visible = episodes.any { !it.episode.seen } && selected.isEmpty(), visible = episodes.any { !it.episode.seen } && episodes.none { it.selected },
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut(),
) { ) {
@ -521,8 +506,8 @@ fun AnimeScreenLargeImpl(
} }
Text(text = stringResource(id)) Text(text = stringResource(id))
}, },
icon = { Icon(imageVector = Icons.Default.PlayArrow, contentDescription = null) }, icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) },
onClick = onContinueReading, onClick = onContinueWatching,
expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(), expanded = episodeListState.isScrollingUp() || episodeListState.isScrolledToEnd(),
modifier = Modifier modifier = Modifier
.padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()), .padding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues()),
@ -530,75 +515,82 @@ fun AnimeScreenLargeImpl(
} }
}, },
) { contentPadding -> ) { contentPadding ->
Row { TwoPanelBox(
val withNavBarContentPadding = contentPadding + startContent = {
WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues() Column(
AnimeInfoHeader(
modifier = Modifier modifier = Modifier
.weight(1f) .verticalScroll(rememberScrollState()),
.verticalScroll(rememberScrollState()) ) {
.padding(bottom = withNavBarContentPadding.calculateBottomPadding()), AnimeInfoBox(
windowWidthSizeClass = WindowWidthSizeClass.Expanded, isTabletUi = true,
appBarPadding = contentPadding.calculateTopPadding(), appBarPadding = contentPadding.calculateTopPadding(),
title = state.anime.title, title = state.anime.title,
author = state.anime.author, author = state.anime.author,
artist = state.anime.artist, artist = state.anime.artist,
description = state.anime.description,
tagsProvider = { state.anime.genre },
sourceName = remember { state.source.getNameForAnimeInfo() }, sourceName = remember { state.source.getNameForAnimeInfo() },
isStubSource = remember { state.source is AnimeSourceManager.StubSource }, isStubSource = remember { state.source is AnimeSourceManager.StubAnimeSource },
coverDataProvider = { state.anime }, coverDataProvider = { state.anime },
favorite = state.anime.favorite,
status = state.anime.status, status = state.anime.status,
trackingCount = state.trackingCount,
fromSource = state.isFromSource,
onAddToLibraryClicked = onAddToLibraryClicked,
onWebViewClicked = onWebViewClicked,
onTrackingClicked = onTrackingClicked,
onTagClicked = onTagClicked,
onEditCategory = onEditCategoryClicked,
onCoverClick = onCoverClicked, onCoverClick = onCoverClicked,
doSearch = onSearch, doSearch = onSearch,
) )
AnimeActionRow(
val episodesWeight = if (windowWidthSizeClass == WindowWidthSizeClass.Medium) 1f else 2f 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( VerticalFastScroller(
listState = episodeListState, listState = episodeListState,
modifier = Modifier.weight(episodesWeight), topContentPadding = contentPadding.calculateTopPadding(),
topContentPadding = withNavBarContentPadding.calculateTopPadding(),
endContentPadding = withNavBarContentPadding.calculateEndPadding(layoutDirection),
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.fillMaxHeight(),
state = episodeListState, 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( EpisodeHeader(
episodeCount = episodes.size, episodeCount = episodes.size,
isEpisodeFiltered = state.anime.episodesFiltered(), onClick = onFilterButtonClicked,
onFilterButtonClicked = onFilterButtonClicked,
) )
} }
sharedEpisodeItems( sharedEpisodeItems(
episodes = episodes, episodes = episodes,
state = state,
selected = selected,
selectedPositions = selectedPositions,
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
onDownloadEpisode = onDownloadEpisode, onDownloadEpisode = onDownloadEpisode,
onEpisodeSelected = onEpisodeSelected,
) )
} }
} }
} },
)
} }
} }
} }
@Composable @Composable
private fun SharedAnimeBottomActionMenu( private fun SharedAnimeBottomActionMenu(
selected: SnapshotStateList<EpisodeItem>, selected: List<EpisodeItem>,
modifier: Modifier = Modifier,
onEpisodeClicked: (Episode, Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit,
onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit, onMultiBookmarkClicked: (List<Episode>, bookmarked: Boolean) -> Unit,
onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit, onMultiMarkAsSeenClicked: (List<Episode>, markAsSeen: Boolean) -> Unit,
@ -607,225 +599,97 @@ private fun SharedAnimeBottomActionMenu(
onMultiDeleteClicked: (List<Episode>) -> Unit, onMultiDeleteClicked: (List<Episode>) -> Unit,
fillFraction: Float, fillFraction: Float,
) { ) {
val preferences: PreferencesHelper = Injekt.get() val preferences: PlayerPreferences = Injekt.get()
AnimeBottomActionMenu( AnimeBottomActionMenu(
visible = selected.isNotEmpty(), visible = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth(fillFraction), modifier = modifier.fillMaxWidth(fillFraction),
onBookmarkClicked = { onBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.episode }, true) onMultiBookmarkClicked.invoke(selected.map { it.episode }, true)
selected.clear()
}.takeIf { selected.any { !it.episode.bookmark } }, }.takeIf { selected.any { !it.episode.bookmark } },
onRemoveBookmarkClicked = { onRemoveBookmarkClicked = {
onMultiBookmarkClicked.invoke(selected.map { it.episode }, false) onMultiBookmarkClicked.invoke(selected.map { it.episode }, false)
selected.clear()
}.takeIf { selected.all { it.episode.bookmark } }, }.takeIf { selected.all { it.episode.bookmark } },
onMarkAsSeenClicked = { onMarkAsSeenClicked = {
onMultiMarkAsSeenClicked(selected.map { it.episode }, true) onMultiMarkAsSeenClicked(selected.map { it.episode }, true)
selected.clear()
}.takeIf { selected.any { !it.episode.seen } }, }.takeIf { selected.any { !it.episode.seen } },
onMarkAsUnseenClicked = { onMarkAsUnseenClicked = {
onMultiMarkAsSeenClicked(selected.map { it.episode }, false) onMultiMarkAsSeenClicked(selected.map { it.episode }, false)
selected.clear() }.takeIf { selected.any { it.episode.seen || it.episode.lastSecondSeen > 0L } },
}.takeIf { selected.any { it.episode.seen } },
onMarkPreviousAsSeenClicked = { onMarkPreviousAsSeenClicked = {
onMarkPreviousAsSeenClicked(selected[0].episode) onMarkPreviousAsSeenClicked(selected[0].episode)
selected.clear()
}.takeIf { selected.size == 1 }, }.takeIf { selected.size == 1 },
onDownloadClicked = { onDownloadClicked = {
onDownloadEpisode!!(selected.toList(), EpisodeDownloadAction.START) onDownloadEpisode!!(selected.toList(), EpisodeDownloadAction.START)
selected.clear()
}.takeIf { }.takeIf {
onDownloadEpisode != null && selected.any { it.downloadState != AnimeDownload.State.DOWNLOADED } onDownloadEpisode != null && selected.any { it.downloadState != AnimeDownload.State.DOWNLOADED }
}, },
onDeleteClicked = { onDeleteClicked = {
onMultiDeleteClicked(selected.map { it.episode }) onMultiDeleteClicked(selected.map { it.episode })
selected.clear()
}.takeIf { }.takeIf {
onDownloadEpisode != null && selected.any { it.downloadState == AnimeDownload.State.DOWNLOADED } onDownloadEpisode != null && selected.any { it.downloadState == AnimeDownload.State.DOWNLOADED }
}, },
onExternalClicked = { onExternalClicked = {
onEpisodeClicked(selected.map { it.episode }.first(), true) onEpisodeClicked(selected.map { it.episode }.first(), true)
selected.clear() }.takeIf { !preferences.alwaysUseExternalPlayer().get() && selected.size == 1 },
}.takeIf { !preferences.alwaysUseExternalPlayer() && selected.size == 1 },
onInternalClicked = { onInternalClicked = {
onEpisodeClicked(selected.map { it.episode }.first(), true) onEpisodeClicked(selected.map { it.episode }.first(), true)
selected.clear() }.takeIf { preferences.alwaysUseExternalPlayer().get() && selected.size == 1 },
}.takeIf { preferences.alwaysUseExternalPlayer() && selected.size == 1 },
) )
} }
private fun LazyListScope.sharedEpisodeItems( private fun LazyListScope.sharedEpisodeItems(
episodes: List<EpisodeItem>, episodes: List<EpisodeItem>,
state: AnimeScreenState.Success,
selected: SnapshotStateList<EpisodeItem>,
selectedPositions: Array<Int>,
onEpisodeClicked: (Episode, Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit,
onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?, onDownloadEpisode: ((List<EpisodeItem>, EpisodeDownloadAction) -> Unit)?,
onEpisodeSelected: (EpisodeItem, Boolean, Boolean, Boolean) -> Unit,
) { ) {
items(items = episodes) { episodeItem -> items(
val context = LocalContext.current items = episodes,
key = { "episode-${it.episode.id}" },
contentType = { MangaScreenItem.CHAPTER },
) { episodeItem ->
val haptic = LocalHapticFeedback.current 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( AnimeEpisodeListItem(
title = episodeTitle, title = episodeItem.episodeTitleString,
date = date, date = episodeItem.dateUploadString,
watchProgress = lastSecondSeen?.let { watchProgress = episodeItem.seenProgressString,
if (totalSeconds != null) { scanlator = episodeItem.episode.scanlator.takeIf { !it.isNullOrBlank() },
stringResource( seen = episodeItem.episode.seen,
id = R.string.episode_progress, bookmark = episodeItem.episode.bookmark,
formatProgress(lastSecondSeen), selected = episodeItem.selected,
formatProgress(totalSeconds), downloadStateProvider = { episodeItem.downloadState },
) downloadProgressProvider = { episodeItem.downloadProgress },
} 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,
onLongClick = { onLongClick = {
val dispatched = onEpisodeItemLongClick( onEpisodeSelected(episodeItem, !episodeItem.selected, true, true)
episodeItem = episodeItem, haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selected = selected,
episodes = episodes,
selectedPositions = selectedPositions,
)
if (dispatched) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}, },
onClick = { onClick = {
onEpisodeItemClick( onEpisodeItemClick(
episodeItem = episodeItem, episodeItem = episodeItem,
selected = selected,
episodes = episodes, episodes = episodes,
selectedPositions = selectedPositions, onToggleSelection = { onEpisodeSelected(episodeItem, !episodeItem.selected, true, false) },
onEpisodeClicked = onEpisodeClicked, onEpisodeClicked = onEpisodeClicked,
) )
}, },
onDownloadClick = if (onDownloadEpisode != null) { onDownloadClick = if (onDownloadEpisode != null) {
{ onDownloadEpisode(listOf(episodeItem), it) } { 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( private fun onEpisodeItemClick(
episodeItem: EpisodeItem, episodeItem: EpisodeItem,
selected: MutableList<EpisodeItem>,
episodes: List<EpisodeItem>, episodes: List<EpisodeItem>,
selectedPositions: Array<Int>, onToggleSelection: (Boolean) -> Unit,
onEpisodeClicked: (Episode, Boolean) -> Unit, onEpisodeClicked: (Episode, Boolean) -> Unit,
) { ) {
val selectedIndex = episodes.indexOf(episodeItem)
when { when {
selected.contains(episodeItem) -> { episodeItem.selected -> onToggleSelection(false)
val removedIndex = episodes.indexOf(episodeItem) episodes.any { it.selected } -> onToggleSelection(true)
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)
}
else -> onEpisodeClicked(episodeItem.episode, false) 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.padding
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.material.icons.Icons 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.Edit
import androidx.compose.material.icons.outlined.Save import androidx.compose.material.icons.outlined.Save
import androidx.compose.material.icons.outlined.Share 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
@ -63,7 +66,7 @@ fun AnimeCoverDialog(
) { ) {
IconButton(onClick = onDismissRequest) { IconButton(onClick = onDismissRequest) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Outlined.Close,
contentDescription = stringResource(R.string.action_close), contentDescription = stringResource(R.string.action_close),
) )
} }
@ -82,9 +85,15 @@ fun AnimeCoverDialog(
} }
if (onEditClick != null) { if (onEditClick != null) {
Box { Box {
val (expanded, onExpand) = remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
IconButton( IconButton(
onClick = { if (isCustomCover) onExpand(true) else onEditClick(EditCoverAction.EDIT) }, onClick = {
if (isCustomCover) {
expanded = true
} else {
onEditClick(EditCoverAction.EDIT)
}
},
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Edit, imageVector = Icons.Outlined.Edit,
@ -93,20 +102,21 @@ fun AnimeCoverDialog(
} }
DropdownMenu( DropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { onExpand(false) }, onDismissRequest = { expanded = false },
offset = DpOffset(8.dp, 0.dp),
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_edit)) }, text = { Text(text = stringResource(R.string.action_edit)) },
onClick = { onClick = {
onEditClick(EditCoverAction.EDIT) onEditClick(EditCoverAction.EDIT)
onExpand(false) expanded = false
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text(text = stringResource(R.string.action_delete)) }, text = { Text(text = stringResource(R.string.action_delete)) },
onClick = { onClick = {
onEditClick(EditCoverAction.DELETE) 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import eu.kanade.presentation.components.EpisodeDownloadAction
import eu.kanade.presentation.components.EpisodeDownloadIndicator import eu.kanade.presentation.components.EpisodeDownloadIndicator
import eu.kanade.presentation.manga.EpisodeDownloadAction
import eu.kanade.presentation.manga.components.DotSeparatorText import eu.kanade.presentation.manga.components.DotSeparatorText
import eu.kanade.presentation.util.ReadItemAlpha
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.download.model.AnimeDownload import eu.kanade.tachiyomi.data.animedownload.model.AnimeDownload
@Composable @Composable
fun AnimeEpisodeListItem( fun AnimeEpisodeListItem(
@ -45,8 +46,8 @@ fun AnimeEpisodeListItem(
seen: Boolean, seen: Boolean,
bookmark: Boolean, bookmark: Boolean,
selected: Boolean, selected: Boolean,
downloadState: AnimeDownload.State, downloadStateProvider: () -> AnimeDownload.State,
downloadProgress: Int, downloadProgressProvider: () -> Int,
onLongClick: () -> Unit, onLongClick: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
onDownloadClick: ((EpisodeDownloadAction) -> Unit)?, onDownloadClick: ((EpisodeDownloadAction) -> Unit)?,
@ -66,13 +67,13 @@ fun AnimeEpisodeListItem(
} else { } else {
MaterialTheme.colorScheme.onSurface 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) { Row(verticalAlignment = Alignment.CenterVertically) {
var textHeight by remember { mutableStateOf(0) } var textHeight by remember { mutableStateOf(0) }
if (bookmark) { if (bookmark) {
Icon( Icon(
imageVector = Icons.Default.Bookmark, imageVector = Icons.Filled.Bookmark,
contentDescription = stringResource(R.string.action_filter_bookmarked), contentDescription = stringResource(R.string.action_filter_bookmarked),
modifier = Modifier modifier = Modifier
.sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }),
@ -82,8 +83,8 @@ fun AnimeEpisodeListItem(
} }
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.bodyMedium color = textColor,
.copy(color = textColor), style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
onTextLayout = { textHeight = it.size.height }, onTextLayout = { textHeight = it.size.height },
@ -109,7 +110,7 @@ fun AnimeEpisodeListItem(
text = watchProgress, text = watchProgress,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.alpha(SeenItemAlpha), modifier = Modifier.alpha(ReadItemAlpha),
) )
if (scanlator != null) DotSeparatorText() if (scanlator != null) DotSeparatorText()
} }
@ -128,12 +129,10 @@ fun AnimeEpisodeListItem(
if (onDownloadClick != null) { if (onDownloadClick != null) {
EpisodeDownloadIndicator( EpisodeDownloadIndicator(
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
downloadState = downloadState, downloadStateProvider = downloadStateProvider,
downloadProgress = downloadProgress, downloadProgressProvider = downloadProgressProvider,
onClick = onDownloadClick, 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.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons 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.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.outlined.AttachMoney
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.outlined.Block
import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.outlined.Done
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.outlined.DoneAll
import androidx.compose.material.icons.filled.Warning 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.Icon
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
@ -42,7 +43,6 @@ import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.SuggestionChip import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue 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.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.components.TextButton
import eu.kanade.presentation.manga.components.DotSeparatorText import eu.kanade.presentation.manga.components.DotSeparatorText
import eu.kanade.presentation.util.clickableNoIndication import eu.kanade.presentation.util.clickableNoIndication
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.presentation.util.secondaryItemAlpha import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.model.SAnime 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)) private val whitespaceLineRegex = Regex("[\\r\\n]{2,}", setOf(RegexOption.MULTILINE))
@Composable @Composable
fun AnimeInfoHeader( fun AnimeInfoBox(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
windowWidthSizeClass: WindowWidthSizeClass, isTabletUi: Boolean,
appBarPadding: Dp, appBarPadding: Dp,
title: String, title: String,
author: String?, author: String?,
artist: String?, artist: String?,
description: String?,
tagsProvider: () -> List<String>?,
sourceName: String, sourceName: String,
isStubSource: Boolean, isStubSource: Boolean,
coverDataProvider: () -> Anime, coverDataProvider: () -> Anime,
favorite: Boolean,
status: Long, status: Long,
trackingCount: Int,
fromSource: Boolean,
onAddToLibraryClicked: () -> Unit,
onWebViewClicked: (() -> Unit)?,
onTrackingClicked: (() -> Unit)?,
onTagClicked: (String) -> Unit,
onEditCategory: (() -> Unit)?,
onCoverClick: () -> Unit, onCoverClick: () -> Unit,
doSearch: (query: String, global: Boolean) -> Unit, doSearch: (query: String, global: Boolean) -> Unit,
) { ) {
val context = LocalContext.current Box(modifier = modifier) {
Column(modifier = modifier) {
Box {
// Backdrop // Backdrop
val backdropGradientColors = listOf( val backdropGradientColors = listOf(
Color.Transparent, Color.Transparent,
@ -133,15 +121,15 @@ fun AnimeInfoHeader(
.alpha(.2f), .alpha(.2f),
) )
// Anime & source info // Manga & source info
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
if (windowWidthSizeClass == WindowWidthSizeClass.Compact) { if (!isTabletUi) {
AnimeAndSourceTitlesSmall( AnimeAndSourceTitlesSmall(
appBarPadding = appBarPadding, appBarPadding = appBarPadding,
coverDataProvider = coverDataProvider, coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick, onCoverClick = onCoverClick,
title = title, title = title,
context = context, context = LocalContext.current,
doSearch = doSearch, doSearch = doSearch,
author = author, author = author,
artist = artist, artist = artist,
@ -155,7 +143,7 @@ fun AnimeInfoHeader(
coverDataProvider = coverDataProvider, coverDataProvider = coverDataProvider,
onCoverClick = onCoverClick, onCoverClick = onCoverClick,
title = title, title = title,
context = context, context = LocalContext.current,
doSearch = doSearch, doSearch = doSearch,
author = author, author = author,
artist = artist, artist = artist,
@ -166,9 +154,19 @@ fun AnimeInfoHeader(
} }
} }
} }
}
// Action buttons @Composable
Row(modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp)) { 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) val defaultActionButtonColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .38f)
AnimeActionButton( AnimeActionButton(
title = if (favorite) { title = if (favorite) {
@ -176,7 +174,7 @@ fun AnimeInfoHeader(
} else { } else {
stringResource(R.string.add_to_library) 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, color = if (favorite) MaterialTheme.colorScheme.primary else defaultActionButtonColor,
onClick = onAddToLibraryClicked, onClick = onAddToLibraryClicked,
onLongClick = onEditCategory, onLongClick = onEditCategory,
@ -186,9 +184,9 @@ fun AnimeInfoHeader(
title = if (trackingCount == 0) { title = if (trackingCount == 0) {
stringResource(R.string.manga_tracking_tab) stringResource(R.string.manga_tracking_tab)
} else { } 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, color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary,
onClick = onTrackingClicked, onClick = onTrackingClicked,
) )
@ -196,20 +194,28 @@ fun AnimeInfoHeader(
if (onWebViewClicked != null) { if (onWebViewClicked != null) {
AnimeActionButton( AnimeActionButton(
title = stringResource(R.string.action_web_view), title = stringResource(R.string.action_web_view),
icon = Icons.Default.Public, icon = Icons.Outlined.Public,
color = defaultActionButtonColor, color = defaultActionButtonColor,
onClick = onWebViewClicked, onClick = onWebViewClicked,
) )
} }
} }
}
// Expandable description-tags @Composable
Column { fun ExpandableAnimeDescription(
modifier: Modifier = Modifier,
defaultExpandState: Boolean,
description: String?,
tagsProvider: () -> List<String>?,
onTagClicked: (String) -> Unit,
) {
Column(modifier = modifier) {
val (expanded, onExpanded) = rememberSaveable { val (expanded, onExpanded) = rememberSaveable {
mutableStateOf(fromSource || windowWidthSizeClass != WindowWidthSizeClass.Compact) mutableStateOf(defaultExpandState)
} }
val desc = 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) { val trimmedDescription = remember(desc) {
desc desc
.replace(whitespaceLineRegex, "\n") .replace(whitespaceLineRegex, "\n")
@ -222,10 +228,7 @@ fun AnimeInfoHeader(
modifier = Modifier modifier = Modifier
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.clickableNoIndication( .clickableNoIndication { onExpanded(!expanded) },
onLongClick = { context.copyToClipboard(desc, desc) },
onClick = { onExpanded(!expanded) },
),
) )
val tags = tagsProvider() val tags = tagsProvider()
if (!tags.isNullOrEmpty()) { if (!tags.isNullOrEmpty()) {
@ -264,7 +267,6 @@ fun AnimeInfoHeader(
} }
} }
} }
}
} }
@Composable @Composable
@ -288,13 +290,14 @@ private fun AnimeAndSourceTitlesLarge(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
MangaCover.Book( MangaCover.Book(
modifier = Modifier.fillMaxWidth(0.4f), modifier = Modifier.fillMaxWidth(0.65f),
data = coverDataProvider(), data = coverDataProvider(),
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick, onClick = onCoverClick,
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = title.takeIf { it.isNotBlank() } ?: stringResource(R.string.unknown), text = title.ifBlank { stringResource(R.string.unknown_title) },
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication( modifier = Modifier.clickableNoIndication(
onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) }, onLongClick = { if (title.isNotBlank()) context.copyToClipboard(title, title) },
@ -311,10 +314,12 @@ private fun AnimeAndSourceTitlesLarge(
.padding(top = 2.dp) .padding(top = 2.dp)
.clickableNoIndication( .clickableNoIndication(
onLongClick = { onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard( if (!author.isNullOrBlank()) {
context.copyToClipboard(
author, author,
author, author,
) )
}
}, },
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) }, onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
), ),
@ -341,13 +346,13 @@ private fun AnimeAndSourceTitlesLarge(
) { ) {
Icon( Icon(
imageVector = when (status) { imageVector = when (status) {
SAnime.ONGOING.toLong() -> Icons.Default.Schedule SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule
SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll
SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done
SAnime.CANCELLED.toLong() -> Icons.Default.Close SAnime.CANCELLED.toLong() -> Icons.Outlined.Close
SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause
else -> Icons.Default.Block else -> Icons.Outlined.Block
}, },
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
@ -371,7 +376,7 @@ private fun AnimeAndSourceTitlesLarge(
DotSeparatorText() DotSeparatorText()
if (isStubSource) { if (isStubSource) {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Outlined.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(end = 4.dp) .padding(end = 4.dp)
@ -415,18 +420,21 @@ private fun AnimeAndSourceTitlesSmall(
.sizeIn(maxWidth = 100.dp) .sizeIn(maxWidth = 100.dp)
.align(Alignment.Top), .align(Alignment.Top),
data = coverDataProvider(), data = coverDataProvider(),
contentDescription = stringResource(R.string.manga_cover),
onClick = onCoverClick, onClick = onCoverClick,
) )
Column(modifier = Modifier.padding(start = 16.dp)) { Column(modifier = Modifier.padding(start = 16.dp)) {
Text( Text(
text = title.ifBlank { stringResource(R.string.unknown) }, text = title.ifBlank { stringResource(R.string.unknown_title) },
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clickableNoIndication( modifier = Modifier.clickableNoIndication(
onLongClick = { onLongClick = {
if (title.isNotBlank()) context.copyToClipboard( if (title.isNotBlank()) {
context.copyToClipboard(
title, title,
title, title,
) )
}
}, },
onClick = { if (title.isNotBlank()) doSearch(title, true) }, onClick = { if (title.isNotBlank()) doSearch(title, true) },
), ),
@ -441,10 +449,12 @@ private fun AnimeAndSourceTitlesSmall(
.padding(top = 2.dp) .padding(top = 2.dp)
.clickableNoIndication( .clickableNoIndication(
onLongClick = { onLongClick = {
if (!author.isNullOrBlank()) context.copyToClipboard( if (!author.isNullOrBlank()) {
context.copyToClipboard(
author, author,
author, author,
) )
}
}, },
onClick = { if (!author.isNullOrBlank()) doSearch(author, true) }, onClick = { if (!author.isNullOrBlank()) doSearch(author, true) },
), ),
@ -469,13 +479,13 @@ private fun AnimeAndSourceTitlesSmall(
) { ) {
Icon( Icon(
imageVector = when (status) { imageVector = when (status) {
SAnime.ONGOING.toLong() -> Icons.Default.Schedule SAnime.ONGOING.toLong() -> Icons.Outlined.Schedule
SAnime.COMPLETED.toLong() -> Icons.Default.DoneAll SAnime.COMPLETED.toLong() -> Icons.Outlined.DoneAll
SAnime.LICENSED.toLong() -> Icons.Default.AttachMoney SAnime.LICENSED.toLong() -> Icons.Outlined.AttachMoney
SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Default.Done SAnime.PUBLISHING_FINISHED.toLong() -> Icons.Outlined.Done
SAnime.CANCELLED.toLong() -> Icons.Default.Close SAnime.CANCELLED.toLong() -> Icons.Outlined.Close
SAnime.ON_HIATUS.toLong() -> Icons.Default.Pause SAnime.ON_HIATUS.toLong() -> Icons.Outlined.Pause
else -> Icons.Default.Block else -> Icons.Outlined.Block
}, },
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
@ -499,7 +509,7 @@ private fun AnimeAndSourceTitlesSmall(
DotSeparatorText() DotSeparatorText()
if (isStubSource) { if (isStubSource) {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Outlined.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.padding(end = 4.dp) .padding(end = 4.dp)
@ -555,6 +565,7 @@ private fun AnimeSummary(
expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0 expandedHeight = expandedPlaceable.maxByOrNull { it.height }?.height?.coerceAtLeast(shrunkHeight) ?: 0
val actualPlaceable = subcompose("description") { val actualPlaceable = subcompose("description") {
SelectionContainer {
Text( Text(
text = if (expanded) expandedDescription else shrunkDescription, text = if (expanded) expandedDescription else shrunkDescription,
maxLines = Int.MAX_VALUE, maxLines = Int.MAX_VALUE,
@ -562,6 +573,7 @@ private fun AnimeSummary(
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.secondaryItemAlpha(), modifier = Modifier.secondaryItemAlpha(),
) )
}
}.map { it.measure(constraints) } }.map { it.measure(constraints) }
val scrimPlaceable = subcompose("scrim") { val scrimPlaceable = subcompose("scrim") {
@ -573,7 +585,7 @@ private fun AnimeSummary(
val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down) val image = AnimatedImageVector.animatedVectorResource(R.drawable.anim_caret_down)
Icon( Icon(
painter = rememberAnimatedVectorPainter(image, !expanded), 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, tint = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.background(Brush.radialGradient(colors = colors.asReversed())), 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.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.presentation.util.quantityStringResource
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.getResourceColor
@Composable @Composable
fun EpisodeHeader( fun EpisodeHeader(
episodeCount: Int?, episodeCount: Int?,
isEpisodeFiltered: Boolean, onClick: () -> Unit,
onFilterButtonClicked: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(onClick = onFilterButtonClicked) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@ -37,20 +30,11 @@ fun EpisodeHeader(
text = if (episodeCount == null) { text = if (episodeCount == null) {
stringResource(R.string.episodes) stringResource(R.string.episodes)
} else { } else {
quantityStringResource(id = R.plurals.anime_num_episodes, quantity = episodeCount) pluralStringResource(id = R.plurals.anime_num_episodes, count = episodeCount, episodeCount)
}, },
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onBackground, 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 android.util.DisplayMetrics
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
@ -30,58 +32,116 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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_ALPHA
import eu.kanade.presentation.components.Divider import eu.kanade.presentation.components.Divider
import eu.kanade.presentation.components.EmptyScreen import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.components.LoadingScreen
import eu.kanade.presentation.components.PreferenceRow import eu.kanade.presentation.components.PreferenceRow
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.components.ScrollbarLazyColumn import eu.kanade.presentation.components.ScrollbarLazyColumn
import eu.kanade.presentation.util.horizontalPadding import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animeextension.model.AnimeExtension
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource 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.AnimeExtensionDetailsPresenter
import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem import eu.kanade.tachiyomi.ui.browse.animeextension.details.AnimeExtensionSourceItem
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable @Composable
fun AnimeExtensionDetailsScreen( fun AnimeExtensionDetailsScreen(
nestedScrollInterop: NestedScrollConnection, navigateUp: () -> Unit,
presenter: AnimeExtensionDetailsPresenter, presenter: AnimeExtensionDetailsPresenter,
onClickUninstall: () -> Unit,
onClickAppInfo: () -> Unit,
onClickSourcePreferences: (sourceId: Long) -> Unit, onClickSourcePreferences: (sourceId: Long) -> Unit,
onClickSource: (sourceId: Long) -> Unit,
) { ) {
val extension = presenter.extension val uriHandler = LocalUriHandler.current
if (extension == null) { Scaffold(
EmptyScreen(textResource = R.string.empty_screen) topBar = { scrollBehavior ->
return 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) } var showNsfwWarning by remember { mutableStateOf(false) }
ScrollbarLazyColumn( ScrollbarLazyColumn(
modifier = Modifier.nestedScroll(nestedScrollInterop), contentPadding = contentPadding,
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) { ) {
when { when {
extension.isUnofficial -> extension.isUnofficial ->
@ -97,8 +157,13 @@ fun AnimeExtensionDetailsScreen(
item { item {
DetailsHeader( DetailsHeader(
extension = extension, extension = extension,
onClickUninstall = onClickUninstall, onClickUninstall = { presenter.uninstallExtension() },
onClickAppInfo = onClickAppInfo, onClickAppInfo = {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", extension.pkgName, null)
context.startActivity(this)
}
},
onClickAgeRating = { onClickAgeRating = {
showNsfwWarning = true showNsfwWarning = true
}, },
@ -106,14 +171,14 @@ fun AnimeExtensionDetailsScreen(
} }
items( items(
items = sources, items = presenter.sources,
key = { it.source.id }, key = { it.source.id },
) { source -> ) { source ->
SourceSwitchPreference( SourceSwitchPreference(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
source = source, source = source,
onClickSourcePreferences = onClickSourcePreferences, onClickSourcePreferences = onClickSourcePreferences,
onClickSource = onClickSource, onClickSource = { presenter.toggleSource(it) },
) )
} }
} }
@ -124,23 +189,22 @@ fun AnimeExtensionDetailsScreen(
}, },
) )
} }
}
}
} }
@Composable @Composable
private fun WarningBanner(@StringRes textRes: Int) { private fun WarningBanner(@StringRes textRes: Int) {
Box( Text(
text = stringResource(textRes),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.error) .background(MaterialTheme.colorScheme.error)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(textRes),
color = MaterialTheme.colorScheme.onError, color = MaterialTheme.colorScheme.onError,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
) )
}
} }
@Composable @Composable
@ -164,7 +228,7 @@ private fun DetailsHeader(
), ),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
ExtensionIcon( AnimeExtensionIcon(
modifier = Modifier modifier = Modifier
.size(112.dp), .size(112.dp),
extension = extension, extension = extension,
@ -268,7 +332,9 @@ private fun InfoText(
val clickableModifier = if (onClick != null) { val clickableModifier = if (onClick != null) {
Modifier.clickable(interactionSource, indication = null) { onClick() } Modifier.clickable(interactionSource, indication = null) { onClick() }
} else Modifier } else {
Modifier
}
Column( Column(
modifier = modifier.then(clickableModifier), 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.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope 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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.flowlayout.FlowRow
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState 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.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.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.horizontalPadding
import eu.kanade.presentation.util.plus import eu.kanade.presentation.util.plus
import eu.kanade.presentation.util.secondaryItemAlpha
import eu.kanade.presentation.util.topPaddingValues import eu.kanade.presentation.util.topPaddingValues
import eu.kanade.tachiyomi.R 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.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.AnimeExtensionsPresenter
import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionState
import eu.kanade.tachiyomi.ui.browse.animeextension.ExtensionUiModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable @Composable
fun AnimeExtensionScreen( fun AnimeExtensionScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: AnimeExtensionsPresenter, presenter: AnimeExtensionsPresenter,
contentPadding: PaddingValues,
onLongClickItem: (AnimeExtension) -> Unit, onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit,
onInstallExtension: (AnimeExtension.Available) -> Unit, onInstallExtension: (AnimeExtension.Available) -> Unit,
@ -63,21 +66,22 @@ fun AnimeExtensionScreen(
onOpenExtension: (AnimeExtension.Installed) -> Unit, onOpenExtension: (AnimeExtension.Installed) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onLaunched: () -> Unit,
) { ) {
val state by presenter.state.collectAsState()
val isRefreshing = presenter.isRefreshing
SwipeRefresh( SwipeRefresh(
modifier = Modifier.nestedScroll(nestedScrollInterop), refreshing = presenter.isRefreshing,
state = rememberSwipeRefreshState(isRefreshing),
indicator = { s, trigger -> SwipeRefreshIndicator(s, trigger) },
onRefresh = onRefresh, onRefresh = onRefresh,
enabled = !presenter.isLoading,
) { ) {
when (state) { when {
is ExtensionState.Initialized -> { presenter.isLoading -> LoadingScreen()
ExtensionContent( presenter.isEmpty -> EmptyScreen(
items = (state as ExtensionState.Initialized).list, textResource = R.string.empty_screen,
modifier = Modifier.padding(contentPadding),
)
else -> {
AnimeExtensionContent(
state = presenter,
contentPadding = contentPadding,
onLongClickItem = onLongClickItem, onLongClickItem = onLongClickItem,
onClickItemCancel = onClickItemCancel, onClickItemCancel = onClickItemCancel,
onInstallExtension = onInstallExtension, onInstallExtension = onInstallExtension,
@ -86,17 +90,16 @@ fun AnimeExtensionScreen(
onTrustExtension = onTrustExtension, onTrustExtension = onTrustExtension,
onOpenExtension = onOpenExtension, onOpenExtension = onOpenExtension,
onClickUpdateAll = onClickUpdateAll, onClickUpdateAll = onClickUpdateAll,
onLaunched = onLaunched,
) )
} }
ExtensionState.Uninitialized -> {}
} }
} }
} }
@Composable @Composable
fun ExtensionContent( private fun AnimeExtensionContent(
items: List<ExtensionUiModel>, state: AnimeExtensionsState,
contentPadding: PaddingValues,
onLongClickItem: (AnimeExtension) -> Unit, onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit,
onInstallExtension: (AnimeExtension.Available) -> Unit, onInstallExtension: (AnimeExtension.Available) -> Unit,
@ -105,31 +108,29 @@ fun ExtensionContent(
onTrustExtension: (AnimeExtension.Untrusted) -> Unit, onTrustExtension: (AnimeExtension.Untrusted) -> Unit,
onOpenExtension: (AnimeExtension.Installed) -> Unit, onOpenExtension: (AnimeExtension.Installed) -> Unit,
onClickUpdateAll: () -> Unit, onClickUpdateAll: () -> Unit,
onLaunched: () -> Unit,
) { ) {
var trustState by remember { mutableStateOf<AnimeExtension.Untrusted?>(null) } var trustState by remember { mutableStateOf<AnimeExtension.Untrusted?>(null) }
FastScrollLazyColumn( FastScrollLazyColumn(
contentPadding = WindowInsets.navigationBars.asPaddingValues() + topPaddingValues, contentPadding = contentPadding + topPaddingValues,
) { ) {
items( items(
items = items, items = state.items,
key = {
when (it) {
is ExtensionUiModel.Header.Resource -> it.textRes
is ExtensionUiModel.Header.Text -> it.text
is ExtensionUiModel.Item -> it.key()
}
},
contentType = { contentType = {
when (it) { when (it) {
is ExtensionUiModel.Item -> "item" is AnimeExtensionUiModel.Header -> "header"
else -> "header" is AnimeExtensionUiModel.Item -> "item"
}
},
key = {
when (it) {
is AnimeExtensionUiModel.Header -> "animeextensionHeader-${it.hashCode()}"
is AnimeExtensionUiModel.Item -> "animeextension-${it.hashCode()}"
} }
}, },
) { item -> ) { item ->
when (item) { when (item) {
is ExtensionUiModel.Header.Resource -> { is AnimeExtensionUiModel.Header.Resource -> {
val action: @Composable RowScope.() -> Unit = val action: @Composable RowScope.() -> Unit =
if (item.textRes == R.string.ext_updates_pending) { if (item.textRes == R.string.ext_updates_pending) {
{ {
@ -151,26 +152,20 @@ fun ExtensionContent(
action = action, action = action,
) )
} }
is ExtensionUiModel.Header.Text -> { is AnimeExtensionUiModel.Header.Text -> {
ExtensionHeader( ExtensionHeader(
text = item.text, text = item.text,
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
) )
} }
is ExtensionUiModel.Item -> { is AnimeExtensionUiModel.Item -> {
AnimeExtensionItem( AnimeExtensionItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
item = item, item = item,
onClickItem = { onClickItem = {
when (it) { when (it) {
is AnimeExtension.Available -> onInstallExtension(it) is AnimeExtension.Available -> onInstallExtension(it)
is AnimeExtension.Installed -> { is AnimeExtension.Installed -> onOpenExtension(it)
if (it.hasUpdate) {
onUpdateExtension(it)
} else {
onOpenExtension(it)
}
}
is AnimeExtension.Untrusted -> { trustState = it } is AnimeExtension.Untrusted -> { trustState = it }
} }
}, },
@ -190,9 +185,6 @@ fun ExtensionContent(
} }
}, },
) )
LaunchedEffect(Unit) {
onLaunched()
}
} }
} }
} }
@ -215,9 +207,9 @@ fun ExtensionContent(
} }
@Composable @Composable
fun AnimeExtensionItem( private fun AnimeExtensionItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
item: ExtensionUiModel.Item, item: AnimeExtensionUiModel.Item,
onClickItem: (AnimeExtension) -> Unit, onClickItem: (AnimeExtension) -> Unit,
onLongClickItem: (AnimeExtension) -> Unit, onLongClickItem: (AnimeExtension) -> Unit,
onClickItemCancel: (AnimeExtension) -> Unit, onClickItemCancel: (AnimeExtension) -> Unit,
@ -233,10 +225,30 @@ fun AnimeExtensionItem(
onClickItem = { onClickItem(extension) }, onClickItem = { onClickItem(extension) },
onLongClickItem = { onLongClickItem(extension) }, onLongClickItem = { onLongClickItem(extension) },
icon = { 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 = { action = {
ExtensionItemActions( AnimeExtensionItemActions(
extension = extension, extension = extension,
installStep = installStep, installStep = installStep,
onClickItemCancel = onClickItemCancel, onClickItemCancel = onClickItemCancel,
@ -244,29 +256,20 @@ fun AnimeExtensionItem(
) )
}, },
) { ) {
ExtensionItemContent( AnimeExtensionItemContent(
extension = extension, extension = extension,
installStep = installStep,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
} }
@Composable @Composable
fun ExtensionItemContent( private fun AnimeExtensionItemContent(
extension: AnimeExtension, extension: AnimeExtension,
installStep: InstallStep,
modifier: Modifier = Modifier, 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( Column(
modifier = modifier.padding(start = horizontalPadding), modifier = modifier.padding(start = horizontalPadding),
) { ) {
@ -276,56 +279,72 @@ fun ExtensionItemContent(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Row( // Won't look good but it's not like we can ellipsize overflowing content
horizontalArrangement = Arrangement.spacedBy(4.dp), 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(
text = LocaleHelper.getSourceDisplayName(extension.lang, context), text = LocaleHelper.getSourceDisplayName(extension.lang, LocalContext.current),
style = MaterialTheme.typography.bodySmall,
) )
} }
if (extension.versionName.isNotEmpty()) { if (extension.versionName.isNotEmpty()) {
Text( Text(
text = extension.versionName, 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) { if (warning != null) {
Text( Text(
text = stringResource(id = warning).uppercase(), text = stringResource(warning).uppercase(),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.error, 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 @Composable
fun ExtensionItemActions( private fun AnimeExtensionItemActions(
extension: AnimeExtension, extension: AnimeExtension,
installStep: InstallStep, installStep: InstallStep,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClickItemCancel: (AnimeExtension) -> Unit = {}, onClickItemCancel: (AnimeExtension) -> Unit = {},
onClickItemAction: (AnimeExtension) -> Unit = {}, onClickItemAction: (AnimeExtension) -> Unit = {},
) { ) {
val isIdle = remember(installStep) { val isIdle = installStep.isCompleted()
installStep == InstallStep.Idle || installStep == InstallStep.Error
}
Row(modifier = modifier) { Row(modifier = modifier) {
if (isIdle) {
TextButton( TextButton(
onClick = { onClickItemAction(extension) }, onClick = { onClickItemAction(extension) },
enabled = isIdle,
) { ) {
Text( Text(
text = when (installStep) { 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.Installed -> stringResource(R.string.ext_installed)
InstallStep.Error -> stringResource(R.string.action_retry) InstallStep.Error -> stringResource(R.string.action_retry)
InstallStep.Idle -> { InstallStep.Idle -> {
@ -341,18 +360,15 @@ fun ExtensionItemActions(
is AnimeExtension.Available -> stringResource(R.string.ext_install) 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) }) { IconButton(onClick = { onClickItemCancel(extension) }) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Outlined.Close,
contentDescription = "", contentDescription = stringResource(R.string.action_cancel),
tint = MaterialTheme.colorScheme.onBackground,
) )
} }
} }

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

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