Merge remote-tracking branch 'origin/devel' into devel
commit
6e5519419d
@ -0,0 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
data class MangaSourceInfo(
|
||||
val mangaSource: MangaSource,
|
||||
val isEnabled: Boolean,
|
||||
val isPinned: Boolean,
|
||||
) : MangaSource by mangaSource
|
||||
@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.core.model.parcelable
|
||||
|
||||
import android.os.Parcel
|
||||
import kotlinx.parcelize.Parceler
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
class MangaSourceParceler : Parceler<MangaSource> {
|
||||
|
||||
override fun create(parcel: Parcel): MangaSource = MangaSource(parcel.readString())
|
||||
|
||||
override fun MangaSource.write(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(name)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import android.util.Log
|
||||
import androidx.collection.MutableLongSet
|
||||
import coil.request.CachePolicy
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainCoroutineDispatcher
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.cache.SafeDeferred
|
||||
import org.koitharu.kotatsu.core.util.MultiMutex
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
|
||||
abstract class CachingMangaRepository(
|
||||
private val cache: MemoryContentCache,
|
||||
) : MangaRepository {
|
||||
|
||||
private val detailsMutex = MultiMutex<Long>()
|
||||
private val relatedMangaMutex = MultiMutex<Long>()
|
||||
private val pagesMutex = MultiMutex<Long>()
|
||||
|
||||
final override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
|
||||
|
||||
final override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = pagesMutex.withLock(chapter.id) {
|
||||
cache.getPages(source, chapter.url)?.let { return it }
|
||||
val pages = asyncSafe {
|
||||
getPagesImpl(chapter).distinctById()
|
||||
}
|
||||
cache.putPages(source, chapter.url, pages)
|
||||
pages
|
||||
}.await()
|
||||
|
||||
final override suspend fun getRelated(seed: Manga): List<Manga> = relatedMangaMutex.withLock(seed.id) {
|
||||
cache.getRelatedManga(source, seed.url)?.let { return it }
|
||||
val related = asyncSafe {
|
||||
getRelatedMangaImpl(seed).filterNot { it.id == seed.id }
|
||||
}
|
||||
cache.putRelatedManga(source, seed.url, related)
|
||||
related
|
||||
}.await()
|
||||
|
||||
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga = detailsMutex.withLock(manga.id) {
|
||||
if (cachePolicy.readEnabled) {
|
||||
cache.getDetails(source, manga.url)?.let { return it }
|
||||
}
|
||||
val details = asyncSafe {
|
||||
getDetailsImpl(manga)
|
||||
}
|
||||
if (cachePolicy.writeEnabled) {
|
||||
cache.putDetails(source, manga.url, details)
|
||||
}
|
||||
details
|
||||
}.await()
|
||||
|
||||
suspend fun peekDetails(manga: Manga): Manga? {
|
||||
return cache.getDetails(source, manga.url)
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
cache.clear(source)
|
||||
}
|
||||
|
||||
protected abstract suspend fun getDetailsImpl(manga: Manga): Manga
|
||||
|
||||
protected abstract suspend fun getRelatedMangaImpl(seed: Manga): List<Manga>
|
||||
|
||||
protected abstract suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage>
|
||||
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
if (dispatcher == null || dispatcher is MainCoroutineDispatcher) {
|
||||
dispatcher = Dispatchers.Default
|
||||
}
|
||||
return SafeDeferred(
|
||||
processLifecycleScope.async(dispatcher) {
|
||||
runCatchingCancellable { block() }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaPage>.distinctById(): List<MangaPage> {
|
||||
if (isEmpty()) {
|
||||
return emptyList()
|
||||
}
|
||||
val result = ArrayList<MangaPage>(size)
|
||||
val set = MutableLongSet(size)
|
||||
for (page in this) {
|
||||
if (set.add(page.id)) {
|
||||
result.add(page)
|
||||
} else if (BuildConfig.DEBUG) {
|
||||
Log.w(null, "Duplicate page: $page")
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -1,38 +1,49 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* This parser is just for parser development, it should not be used in releases
|
||||
*/
|
||||
class EmptyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
class EmptyMangaRepository(override val source: MangaSource) : MangaRepository {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
override val states: Set<MangaState>
|
||||
get() = emptySet()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = emptySet()
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = SortOrder.NEWEST
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = false
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = false
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
override suspend fun getPageUrl(page: MangaPage): String = stub(null)
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = stub(null)
|
||||
|
||||
override suspend fun getRelatedManga(seed: Manga): List<Manga> = stub(seed)
|
||||
override suspend fun getRelated(seed: Manga): List<Manga> = stub(seed)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("This manga source is not supported", manga)
|
||||
@ -0,0 +1,264 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.database.Cursor
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import org.koitharu.kotatsu.core.cache.MemoryContentCache
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.ContentType
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||
import org.koitharu.kotatsu.parsers.util.splitTwoParts
|
||||
import java.util.EnumSet
|
||||
import java.util.Locale
|
||||
|
||||
class ExternalMangaRepository(
|
||||
private val contentResolver: ContentResolver,
|
||||
override val source: ExternalMangaSource,
|
||||
cache: MemoryContentCache,
|
||||
) : CachingMangaRepository(cache) {
|
||||
|
||||
private val capabilities by lazy { queryCapabilities() }
|
||||
|
||||
override val sortOrders: Set<SortOrder>
|
||||
get() = capabilities?.availableSortOrders ?: EnumSet.of(SortOrder.ALPHABETICAL)
|
||||
override val states: Set<MangaState>
|
||||
get() = capabilities?.availableStates.orEmpty()
|
||||
override val contentRatings: Set<ContentRating>
|
||||
get() = capabilities?.availableContentRating.orEmpty()
|
||||
override var defaultSortOrder: SortOrder
|
||||
get() = capabilities?.defaultSortOrder ?: SortOrder.ALPHABETICAL
|
||||
set(value) = Unit
|
||||
override val isMultipleTagsSupported: Boolean
|
||||
get() = capabilities?.isMultipleTagsSupported ?: true
|
||||
override val isTagsExclusionSupported: Boolean
|
||||
get() = capabilities?.isTagsExclusionSupported ?: false
|
||||
override val isSearchSupported: Boolean
|
||||
get() = capabilities?.isSearchSupported ?: true
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> =
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri().buildUpon()
|
||||
uri.appendQueryParameter("offset", offset.toString())
|
||||
when (filter) {
|
||||
is MangaListFilter.Advanced -> {
|
||||
filter.tags.forEach { uri.appendQueryParameter("tag_include", it.key) }
|
||||
filter.tagsExclude.forEach { uri.appendQueryParameter("tag_exclude", it.key) }
|
||||
filter.states.forEach { uri.appendQueryParameter("state", it.name) }
|
||||
filter.locale?.let { uri.appendQueryParameter("locale", it.language) }
|
||||
filter.contentRating.forEach { uri.appendQueryParameter("content_rating", it.name) }
|
||||
}
|
||||
|
||||
is MangaListFilter.Search -> {
|
||||
uri.appendQueryParameter("query", filter.query)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
contentResolver.query(uri.build(), null, null, null, filter?.sortOrder?.name)?.use { cursor ->
|
||||
val result = ArrayList<Manga>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += cursor.getManga()
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getDetailsImpl(manga: Manga): Manga = coroutineScope {
|
||||
val chapters = async { queryChapters(manga.url) }
|
||||
val details = queryDetails(manga.url)
|
||||
Manga(
|
||||
id = manga.id,
|
||||
title = details.title.ifBlank { manga.title },
|
||||
altTitle = details.altTitle.ifNullOrEmpty { manga.altTitle },
|
||||
url = details.url.ifEmpty { manga.url },
|
||||
publicUrl = details.publicUrl.ifEmpty { manga.publicUrl },
|
||||
rating = maxOf(details.rating, manga.rating),
|
||||
isNsfw = details.isNsfw,
|
||||
coverUrl = details.coverUrl.ifEmpty { manga.coverUrl },
|
||||
tags = details.tags + manga.tags,
|
||||
state = details.state ?: manga.state,
|
||||
author = details.author.ifNullOrEmpty { manga.author },
|
||||
largeCoverUrl = details.largeCoverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
description = details.description.ifNullOrEmpty { manga.description },
|
||||
chapters = chapters.await(),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getPagesImpl(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(chapter.url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaPage>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaPage(
|
||||
id = cursor.getLong(0),
|
||||
url = cursor.getString(1),
|
||||
preview = cursor.getStringOrNull(2),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getPageUrl(page: MangaPage): String = page.url
|
||||
|
||||
override suspend fun getTags(): Set<MangaTag> = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/tags".toUri()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArraySet<MangaTag>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaTag(
|
||||
key = cursor.getString(0),
|
||||
title = cursor.getString(1),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}.orEmpty()
|
||||
}
|
||||
|
||||
override suspend fun getLocales(): Set<Locale> = emptySet()
|
||||
|
||||
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = emptyList() // TODO
|
||||
|
||||
private suspend fun queryDetails(url: String): Manga = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
checkNotNull(
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getManga()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun queryChapters(url: String): List<MangaChapter>? = runInterruptible(Dispatchers.Default) {
|
||||
val uri = "content://${source.authority}/manga/chapters".toUri()
|
||||
.buildUpon()
|
||||
.appendPath(url)
|
||||
.build()
|
||||
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
val result = ArrayList<MangaChapter>(cursor.count)
|
||||
if (cursor.moveToFirst()) {
|
||||
do {
|
||||
result += MangaChapter(
|
||||
id = cursor.getLong(0),
|
||||
name = cursor.getString(1),
|
||||
number = cursor.getFloat(2),
|
||||
volume = cursor.getInt(3),
|
||||
url = cursor.getString(4),
|
||||
scanlator = cursor.getStringOrNull(5),
|
||||
uploadDate = cursor.getLong(6),
|
||||
branch = cursor.getStringOrNull(7),
|
||||
source = source,
|
||||
)
|
||||
} while (cursor.moveToNext())
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getManga() = Manga(
|
||||
id = getLong(0),
|
||||
title = getString(1),
|
||||
altTitle = getStringOrNull(2),
|
||||
url = getString(3),
|
||||
publicUrl = getString(4),
|
||||
rating = getFloat(5),
|
||||
isNsfw = getInt(6) > 1,
|
||||
coverUrl = getString(7),
|
||||
tags = getStringOrNull(8)?.split(':')?.mapNotNullToSet {
|
||||
val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null
|
||||
MangaTag(key = parts.first, title = parts.second, source = source)
|
||||
}.orEmpty(),
|
||||
state = getStringOrNull(9)?.let { MangaState.entries.find(it) },
|
||||
author = optString(10),
|
||||
largeCoverUrl = optString(11),
|
||||
description = optString(12),
|
||||
chapters = emptyList(),
|
||||
source = source,
|
||||
)
|
||||
|
||||
private fun Cursor.optString(columnIndex: Int): String? {
|
||||
return if (isNull(columnIndex)) {
|
||||
null
|
||||
} else {
|
||||
getString(columnIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryCapabilities(): MangaSourceCapabilities? {
|
||||
val uri = "content://${source.authority}/capabilities".toUri()
|
||||
return contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
MangaSourceCapabilities(
|
||||
availableSortOrders = cursor.getStringOrNull(0)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(SortOrder::class.java)) {
|
||||
SortOrder.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableStates = cursor.getStringOrNull(1)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(MangaState::class.java)) {
|
||||
MangaState.entries.find(it)
|
||||
}.orEmpty(),
|
||||
availableContentRating = cursor.getStringOrNull(2)
|
||||
?.split(',')
|
||||
?.mapNotNullTo(EnumSet.noneOf(ContentRating::class.java)) {
|
||||
ContentRating.entries.find(it)
|
||||
}.orEmpty(),
|
||||
isMultipleTagsSupported = cursor.getInt(3) > 1,
|
||||
isTagsExclusionSupported = cursor.getInt(4) > 1,
|
||||
isSearchSupported = cursor.getInt(5) > 1,
|
||||
contentType = ContentType.entries.find(cursor.getString(6)) ?: ContentType.OTHER,
|
||||
defaultSortOrder = cursor.getStringOrNull(7)?.let {
|
||||
SortOrder.entries.find(it)
|
||||
} ?: SortOrder.ALPHABETICAL,
|
||||
sourceLocale = cursor.getStringOrNull(8)?.let { Locale(it) } ?: Locale.ROOT,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class MangaSourceCapabilities(
|
||||
val availableSortOrders: Set<SortOrder>,
|
||||
val availableStates: Set<MangaState>,
|
||||
val availableContentRating: Set<ContentRating>,
|
||||
val isMultipleTagsSupported: Boolean,
|
||||
val isTagsExclusionSupported: Boolean,
|
||||
val isSearchSupported: Boolean,
|
||||
val contentType: ContentType,
|
||||
val defaultSortOrder: SortOrder,
|
||||
val sourceLocale: Locale,
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package org.koitharu.kotatsu.core.parser.external
|
||||
|
||||
import android.content.Context
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
data class ExternalMangaSource(
|
||||
val packageName: String,
|
||||
val authority: String,
|
||||
) : MangaSource {
|
||||
|
||||
override val name: String
|
||||
get() = "content:$packageName/$authority"
|
||||
|
||||
private var cachedName: String? = null
|
||||
|
||||
fun isAvailable(context: Context): Boolean {
|
||||
return context.packageManager.resolveContentProvider(authority, 0)?.isEnabled == true
|
||||
}
|
||||
|
||||
fun resolveName(context: Context): String {
|
||||
cachedName?.let {
|
||||
return it
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val info = pm.resolveContentProvider(authority, 0)
|
||||
return info?.loadLabel(pm)?.toString()?.also {
|
||||
cachedName = it
|
||||
} ?: authority
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
enum class ProgressIndicatorMode {
|
||||
|
||||
NONE, PERCENT_READ, PERCENT_LEFT, CHAPTERS_READ, CHAPTERS_LEFT;
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.animation.TimeAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.Animatable
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import kotlin.math.abs
|
||||
|
||||
class AnimatedFaviconDrawable(
|
||||
context: Context,
|
||||
@StyleRes styleResId: Int,
|
||||
name: String,
|
||||
) : FaviconDrawable(context, styleResId, name), Animatable, TimeAnimator.TimeListener {
|
||||
|
||||
private val interpolator = FastOutSlowInInterpolator()
|
||||
private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2
|
||||
private val timeAnimator = TimeAnimator()
|
||||
|
||||
private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||
private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground)
|
||||
|
||||
init {
|
||||
timeAnimator.setTimeListener(this)
|
||||
updateColor()
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
if (!isRunning && period > 0) {
|
||||
updateColor()
|
||||
start()
|
||||
}
|
||||
super.draw(canvas)
|
||||
}
|
||||
|
||||
override fun setAlpha(alpha: Int) = Unit
|
||||
|
||||
override fun getAlpha(): Int = 255
|
||||
|
||||
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
|
||||
callback?.also {
|
||||
updateColor()
|
||||
it.invalidateDrawable(this)
|
||||
} ?: stop()
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
timeAnimator.start()
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
timeAnimator.end()
|
||||
}
|
||||
|
||||
override fun isRunning(): Boolean = timeAnimator.isStarted
|
||||
|
||||
private fun updateColor() {
|
||||
if (period <= 0f) {
|
||||
return
|
||||
}
|
||||
val ph = period / 2
|
||||
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
|
||||
colorForeground = ArgbEvaluatorCompat.getInstance()
|
||||
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,10 @@
|
||||
package org.koitharu.kotatsu.favourites.domain.model
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.find
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
|
||||
data class Cover(
|
||||
val url: String,
|
||||
val source: String,
|
||||
) {
|
||||
val mangaSource: MangaSource?
|
||||
get() = if (source.isEmpty()) null else MangaSource.entries.find(source)
|
||||
val mangaSource by lazy { MangaSource(source) }
|
||||
}
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import dagger.Reusable
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
class ListExtraProvider @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val settings: AppSettings,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
) {
|
||||
|
||||
private val dict by lazy {
|
||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
||||
val set = HashSet<String>()
|
||||
it.bufferedReader().forEachLine { x ->
|
||||
val line = x.trim()
|
||||
if (line.isNotEmpty()) {
|
||||
set.add(line)
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getCounter(mangaId: Long): Int {
|
||||
return if (settings.isTrackerEnabled) {
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getProgress(mangaId: Long): Float {
|
||||
return if (settings.isReadingIndicatorsEnabled) {
|
||||
historyRepository.getProgress(mangaId)
|
||||
} else {
|
||||
PROGRESS_NONE
|
||||
}
|
||||
}
|
||||
|
||||
@ColorRes
|
||||
fun getTagTint(tag: MangaTag): Int {
|
||||
return if (tag.title.lowercase() in dict) {
|
||||
R.color.warning
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,131 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.collection.MutableScatterSet
|
||||
import androidx.collection.ScatterSet
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ListMode
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
|
||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class MangaListMapper @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
private val settings: AppSettings,
|
||||
private val trackingRepository: TrackingRepository,
|
||||
private val historyRepository: HistoryRepository,
|
||||
private val favouritesRepository: FavouritesRepository,
|
||||
) {
|
||||
|
||||
private val dict by lazy { readTagsDict(context) }
|
||||
|
||||
suspend fun toListModelList(manga: Collection<Manga>, mode: ListMode): List<MangaListModel> = manga.map {
|
||||
toListModel(it, mode)
|
||||
}
|
||||
|
||||
suspend fun toListModelList(
|
||||
destination: MutableCollection<in MangaListModel>,
|
||||
manga: Collection<Manga>,
|
||||
mode: ListMode
|
||||
) = manga.mapTo(destination) {
|
||||
toListModel(it, mode)
|
||||
}
|
||||
|
||||
suspend fun toListModel(manga: Manga, mode: ListMode): MangaListModel = when (mode) {
|
||||
ListMode.LIST -> toCompactListModel(manga)
|
||||
ListMode.DETAILED_LIST -> toDetailedListModel(manga)
|
||||
ListMode.GRID -> toGridModel(manga)
|
||||
}
|
||||
|
||||
suspend fun toCompactListModel(manga: Manga) = MangaCompactListModel(
|
||||
id = manga.id,
|
||||
title = manga.title,
|
||||
subtitle = manga.tags.joinToString(", ") { it.title },
|
||||
coverUrl = manga.coverUrl,
|
||||
manga = manga,
|
||||
counter = getCounter(manga.id),
|
||||
progress = getProgress(manga.id),
|
||||
isFavorite = isFavorite(manga.id),
|
||||
)
|
||||
|
||||
suspend fun toDetailedListModel(manga: Manga) = MangaDetailedListModel(
|
||||
id = manga.id,
|
||||
title = manga.title,
|
||||
subtitle = manga.altTitle,
|
||||
coverUrl = manga.coverUrl,
|
||||
manga = manga,
|
||||
counter = getCounter(manga.id),
|
||||
progress = getProgress(manga.id),
|
||||
isFavorite = isFavorite(manga.id),
|
||||
tags = mapTags(manga.tags),
|
||||
)
|
||||
|
||||
suspend fun toGridModel(manga: Manga) = MangaGridModel(
|
||||
id = manga.id,
|
||||
title = manga.title,
|
||||
coverUrl = manga.coverUrl,
|
||||
manga = manga,
|
||||
counter = getCounter(manga.id),
|
||||
progress = getProgress(manga.id),
|
||||
isFavorite = isFavorite(manga.id),
|
||||
)
|
||||
|
||||
fun mapTags(tags: Collection<MangaTag>) = tags.map {
|
||||
ChipsView.ChipModel(
|
||||
tint = getTagTint(it),
|
||||
title = it.title,
|
||||
data = it,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getCounter(mangaId: Long): Int {
|
||||
return if (settings.isTrackerEnabled) {
|
||||
trackingRepository.getNewChaptersCount(mangaId)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getProgress(mangaId: Long): ReadingProgress? {
|
||||
return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
|
||||
}
|
||||
|
||||
private fun isFavorite(mangaId: Long): Boolean {
|
||||
return false // TODO favouritesRepository.isFavorite(mangaId)
|
||||
}
|
||||
|
||||
@ColorRes
|
||||
private fun getTagTint(tag: MangaTag): Int {
|
||||
return if (tag.title.lowercase() in dict) {
|
||||
R.color.warning
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTagsDict(context: Context): ScatterSet<String> =
|
||||
context.resources.openRawResource(R.raw.tags_redlist).use {
|
||||
val set = MutableScatterSet<String>()
|
||||
it.bufferedReader().forEachLine { x ->
|
||||
val line = x.trim()
|
||||
if (line.isNotEmpty()) {
|
||||
set.add(line)
|
||||
}
|
||||
}
|
||||
set.trim()
|
||||
set
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package org.koitharu.kotatsu.list.domain
|
||||
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_LEFT
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.CHAPTERS_READ
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.NONE
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_LEFT
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode.PERCENT_READ
|
||||
|
||||
data class ReadingProgress(
|
||||
val percent: Float,
|
||||
val totalChapters: Int,
|
||||
val mode: ProgressIndicatorMode,
|
||||
) {
|
||||
|
||||
val percentLeft: Float
|
||||
get() = 1f - percent
|
||||
|
||||
val chapters: Int
|
||||
get() = (totalChapters * percent).toInt()
|
||||
|
||||
val chaptersLeft: Int
|
||||
get() = (totalChapters * percentLeft).toInt()
|
||||
|
||||
fun isValid() = when (mode) {
|
||||
NONE -> false
|
||||
PERCENT_READ,
|
||||
PERCENT_LEFT -> percent in 0f..1f
|
||||
|
||||
CHAPTERS_READ,
|
||||
CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f
|
||||
}
|
||||
|
||||
fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaCompactListModel(
|
||||
override val id: Long,
|
||||
override val title: String,
|
||||
val subtitle: String,
|
||||
override val coverUrl: String,
|
||||
override val manga: Manga,
|
||||
override val counter: Int,
|
||||
override val progress: ReadingProgress?,
|
||||
override val isFavorite: Boolean,
|
||||
) : MangaListModel()
|
||||
@ -1,15 +1,17 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaListDetailedModel(
|
||||
data class MangaDetailedListModel(
|
||||
override val id: Long,
|
||||
override val title: String,
|
||||
val subtitle: String?,
|
||||
override val coverUrl: String,
|
||||
override val manga: Manga,
|
||||
override val counter: Int,
|
||||
override val progress: Float,
|
||||
override val progress: ReadingProgress?,
|
||||
override val isFavorite: Boolean,
|
||||
val tags: List<ChipsView.ChipModel>,
|
||||
) : MangaItemModel()
|
||||
) : MangaListModel()
|
||||
@ -1,31 +0,0 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
sealed class MangaItemModel : ListModel {
|
||||
|
||||
abstract val id: Long
|
||||
abstract val manga: Manga
|
||||
abstract val title: String
|
||||
abstract val coverUrl: String
|
||||
abstract val counter: Int
|
||||
abstract val progress: Float
|
||||
|
||||
val source: MangaSource
|
||||
get() = manga.source
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaItemModel && other.javaClass == javaClass && id == other.id
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? {
|
||||
return when {
|
||||
previousState !is MangaItemModel -> super.getChangePayload(previousState)
|
||||
progress != previousState.progress -> ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
|
||||
counter != previousState.counter -> ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,34 @@
|
||||
package org.koitharu.kotatsu.list.ui.model
|
||||
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
|
||||
data class MangaListModel(
|
||||
override val id: Long,
|
||||
override val title: String,
|
||||
val subtitle: String,
|
||||
override val coverUrl: String,
|
||||
override val manga: Manga,
|
||||
override val counter: Int,
|
||||
override val progress: Float,
|
||||
) : MangaItemModel()
|
||||
sealed class MangaListModel : ListModel {
|
||||
|
||||
abstract val id: Long
|
||||
abstract val manga: Manga
|
||||
abstract val title: String
|
||||
abstract val coverUrl: String
|
||||
abstract val counter: Int
|
||||
abstract val isFavorite: Boolean
|
||||
abstract val progress: ReadingProgress?
|
||||
|
||||
val source: MangaSource
|
||||
get() = manga.source
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaListModel && other.javaClass == javaClass && id == other.id
|
||||
}
|
||||
|
||||
override fun getChangePayload(previousState: ListModel): Any? = when {
|
||||
previousState !is MangaListModel || previousState.manga != manga -> null
|
||||
|
||||
previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED
|
||||
previousState.isFavorite != isFavorite || previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue