Compare commits

...

34 Commits
v6.3 ... v6.4.2

Author SHA1 Message Date
Koitharu
ef0cf4766a Fix webtoon mode 2023-11-29 18:07:53 +02:00
Koitharu
910069ec99 Update parsers 2023-11-29 17:25:13 +02:00
Koitharu
d56107bf1f Remove funding info 2023-11-29 09:53:23 +02:00
Koitharu
03426694c8 Bump version 2023-11-29 09:26:45 +02:00
Koitharu
385003bcc8 Fix filter chips #572 2023-11-28 16:11:30 +02:00
Koitharu
225aacff43 Temporary disable downsampling in webtoon mode 2023-11-28 15:57:18 +02:00
Макар Разин
208c0a494b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (531 of 531 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
Bai
0045c7cf44 Translated using Weblate (Turkish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
J. Lavoie
eed7f89518 Translated using Weblate (French)
Currently translated at 99.8% (530 of 531 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
gallegonovato
80c8b9eac0 Translated using Weblate (Spanish)
Currently translated at 100.0% (531 of 531 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
ECBaris
53a680d13c Translated using Weblate (Turkish)
Currently translated at 98.6% (520 of 527 strings)

Co-authored-by: ECBaris <barisaklan@outlook.com.tr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-11-28 15:56:16 +02:00
Koitharu
3e77df20a2 Improve manga memory cache usage 2023-11-26 09:07:56 +02:00
Zakhar Timoshenko
7c1c0a38fa Update parsers lib 2023-11-25 19:53:07 +03:00
Koitharu
012eefe4fe Fix NPE in ListConfigViewModel 2023-11-25 17:40:54 +02:00
Oliullah
cb0f0c70d0 Translated using Weblate (Bengali)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Oliullah <shahin1465686@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translation: Kotatsu/plurals
2023-11-25 17:34:07 +02:00
Макар Разин
23111dfef9 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (525 of 525 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-25 17:34:07 +02:00
gallegonovato
d050c9ad0e Translated using Weblate (Spanish)
Currently translated at 100.0% (525 of 525 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-11-25 17:34:07 +02:00
Koitharu
efd952a91a Localize parsers errors 2023-11-25 17:33:05 +02:00
Koitharu
d3f23ea3a3 Add manga state to filter 2023-11-25 17:25:48 +02:00
Koitharu
acba312e8d Misc fixes 2023-11-25 09:18:08 +02:00
Koitharu
880dd6da27 Load local manga pages directly #552 2023-11-24 18:54:09 +02:00
Koitharu
0c839ce49a Fix locale selection in sources catalog #561 2023-11-24 17:10:58 +02:00
Isira Seneviratne
1afd2d3976 Use file walking APIs 2023-11-24 16:59:01 +02:00
Koitharu
f2d881f9bc Fix crash with locales sorting 2023-11-24 16:58:11 +02:00
Koitharu
c838e57f22 Downsample offscreen pages option 2023-11-24 16:53:44 +02:00
Koitharu
2075b1be19 Add lifecycle for BasePageHolder 2023-11-24 14:59:12 +02:00
Koitharu
cf33cb66c6 Fix release build 2023-11-23 13:25:12 +02:00
Макар Разин
8010c5079b Translated using Weblate (Ukrainian)
Currently translated at 100.0% (524 of 524 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (524 of 524 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (524 of 524 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-11-23 12:47:59 +02:00
Koitharu
a87a77083e Reader fixes 2023-11-23 12:42:43 +02:00
Koitharu
ca20422344 Support Paused manga state 2023-11-23 12:33:48 +02:00
Koitharu
c213b9d4b5 Update parsers 2023-11-23 12:26:38 +02:00
Koitharu
95fbe496cb Search through all sources in catalog 2023-11-22 16:11:23 +02:00
Koitharu
b9fd2e100d Fallback to local manga on network error 2023-11-22 15:38:39 +02:00
Koitharu
1242a88f8e Fix disabling webtoon scale 2023-11-22 15:10:26 +02:00
80 changed files with 967 additions and 398 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +0,0 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 597
versionName = '6.3.0'
versionCode = 602
versionName = '6.4.2'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:41eea1c420') {
implementation('com.github.KotatsuApp:kotatsu-parsers:0efd5437f9') {
exclude group: 'org.json', module: 'json'
}
@@ -134,7 +134,7 @@ dependencies {
implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:cf089a264d'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:771c8753ae'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.config.ConfigKey
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.MangaTag
@@ -19,19 +20,14 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("")
override val sortOrders: Set<SortOrder>
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga {
TODO("Not yet implemented")
}
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> {
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
TODO("Not yet implemented")
}
@@ -39,7 +35,7 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
TODO("Not yet implemented")
}
override suspend fun getTags(): Set<MangaTag> {
override suspend fun getAvailableTags(): Set<MangaTag> {
TODO("Not yet implemented")
}
}

View File

@@ -1,7 +1,7 @@
package org.koitharu.kotatsu.bookmarks.domain
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import java.util.Date
@@ -38,7 +38,6 @@ data class Bookmark(
)
private fun isImageUrlDirect(): Boolean {
val extension = imageUrl.substringAfterLast('.')
return extension.isNotEmpty() && ImageFileFilter().isExtensionValid(extension)
return hasImageExtension(imageUrl)
}
}

View File

@@ -1,12 +1,15 @@
package org.koitharu.kotatsu.core.model
import android.net.Uri
import androidx.annotation.StringRes
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet
@JvmName("mangaIds")
@@ -31,6 +34,15 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
return acc.values.max()
}
@get:StringRes
val MangaState.titleResId: Int
get() = when (this) {
MangaState.ONGOING -> R.string.state_ongoing
MangaState.FINISHED -> R.string.state_finished
MangaState.ABANDONED -> R.string.state_abandoned
MangaState.PAUSED -> R.string.state_paused
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.findById(id)
}

View File

@@ -63,6 +63,9 @@ class MirrorSwitchInterceptor @Inject constructor(
}
synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain
if (currentMirror !in mirrors) {
return@synchronized false
}
addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x)

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser
import android.net.Uri
import coil.request.CachePolicy
import dagger.Reusable
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
@@ -8,6 +9,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.almostEquals
import org.koitharu.kotatsu.parsers.util.levenshteinDistance
@@ -58,7 +60,7 @@ class MangaLinkResolver @Inject constructor(
private suspend fun MangaRepository.findExact(url: String?, title: String?): Manga? {
if (!title.isNullOrEmpty()) {
val list = getList(0, title)
val list = getList(0, MangaListFilter.Search(title))
if (url != null) {
list.find { it.url == url }?.let {
return it
@@ -77,14 +79,14 @@ class MangaLinkResolver @Inject constructor(
}.ifNullOrEmpty {
seed.author
} ?: return@runCatchingCancellable null
val seedList = getList(0, seedTitle)
val seedList = getList(0, MangaListFilter.Search(seedTitle))
seedList.first { x -> x.url == url }
}.getOrThrow()
}
private suspend fun MangaRepository.getDetailsNoCache(manga: Manga): Manga {
return if (this is RemoteMangaRepository) {
getDetails(manga, withCache = false)
getDetails(manga, CachePolicy.READ_ONLY)
} else {
getDetails(manga)
}

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
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.lang.ref.WeakReference
@@ -23,11 +25,13 @@ interface MangaRepository {
val sortOrders: Set<SortOrder>
val states: Set<MangaState>
var defaultSortOrder: SortOrder
suspend fun getList(offset: Int, query: String): List<Manga>
val isMultipleTagsSupported: Boolean
suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga>
suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga>
suspend fun getDetails(manga: Manga): Manga

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import coil.request.CachePolicy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -23,8 +24,10 @@ import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons
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 org.koitharu.kotatsu.parsers.util.domain
@@ -40,7 +43,10 @@ class RemoteMangaRepository(
get() = parser.source
override val sortOrders: Set<SortOrder>
get() = parser.sortOrders
get() = parser.availableSortOrders
override val states: Set<MangaState>
get() = parser.availableStates
override var defaultSortOrder: SortOrder
get() = getConfig().defaultSortOrder ?: sortOrders.first()
@@ -48,6 +54,9 @@ class RemoteMangaRepository(
getConfig().defaultSortOrder = value
}
override val isMultipleTagsSupported: Boolean
get() = parser.isMultipleTagsSupported
var domain: String
get() = parser.domain
set(value) {
@@ -68,19 +77,13 @@ class RemoteMangaRepository(
}
}
override suspend fun getList(offset: Int, query: String): List<Manga> {
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query)
parser.getList(offset, filter)
}
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
}
}
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, CachePolicy.ENABLED)
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it }
@@ -98,7 +101,7 @@ class RemoteMangaRepository(
}
override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags()
parser.getAvailableTags()
}
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
@@ -114,17 +117,18 @@ class RemoteMangaRepository(
return related.await()
}
suspend fun getDetails(manga: Manga, withCache: Boolean): Manga {
if (!withCache) {
return parser.getDetails(manga)
suspend fun getDetails(manga: Manga, cachePolicy: CachePolicy): Manga {
if (cachePolicy.readEnabled) {
cache.getDetails(source, manga.url)?.let { return it }
}
cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe {
mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
}
cache.putDetails(source, manga.url, details)
if (cachePolicy.writeEnabled) {
cache.putDetails(source, manga.url, details)
}
return details.await()
}
@@ -133,7 +137,7 @@ class RemoteMangaRepository(
}
suspend fun find(manga: Manga): Manga? {
val list = getList(0, manga.title)
val list = getList(0, MangaListFilter.Search(manga.title))
return list.find { x -> x.id == manga.id }
}

View File

@@ -109,6 +109,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
val isReaderOptimizationEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false)
var isTrafficWarningEnabled: Boolean
get() = prefs.getBoolean(KEY_TRAFFIC_WARNING, true)
set(value) = prefs.edit { putBoolean(KEY_TRAFFIC_WARNING, value) }
@@ -506,6 +509,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"

View File

@@ -0,0 +1,88 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import android.view.View
import androidx.annotation.CallSuper
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.recyclerview.widget.RecyclerView
abstract class LifecycleAwareViewHolder(
itemView: View,
private val parentLifecycleOwner: LifecycleOwner,
) : RecyclerView.ViewHolder(itemView), LifecycleOwner {
@Suppress("LeakingThis")
final override val lifecycle = LifecycleRegistry(this)
private var isCurrent = false
init {
parentLifecycleOwner.lifecycle.addObserver(ParentLifecycleObserver())
if (parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
}
fun setIsCurrent(value: Boolean) {
isCurrent = value
dispatchResumed()
}
@CallSuper
open fun onStart() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START)
@CallSuper
open fun onResume() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
@CallSuper
open fun onPause() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE)
@CallSuper
open fun onStop() = lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun dispatchResumed() {
val isParentResumed = parentLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if (isCurrent && isParentResumed) {
if (!isResumed()) {
onResume()
}
} else {
if (isResumed()) {
onPause()
}
}
}
protected fun isResumed(): Boolean {
return lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
}
private inner class ParentLifecycleObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
}
override fun onStart(owner: LifecycleOwner) {
onStart()
}
override fun onResume(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onPause(owner: LifecycleOwner) {
dispatchResumed()
}
override fun onStop(owner: LifecycleOwner) {
onStop()
}
override fun onDestroy(owner: LifecycleOwner) {
lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY)
owner.lifecycle.removeObserver(this)
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.core.util.ext.recyclerView
class PagerLifecycleDispatcher(
private val pager: ViewPager2,
) : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
val rv = pager.recyclerView ?: return
for (child in rv.children) {
val wh = rv.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition == position)
}
}
fun invalidate() {
onPageSelected(pager.currentItem)
}
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.core.ui.list.lifecycle
import androidx.core.view.children
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
class RecyclerViewLifecycleDispatcher : RecyclerView.OnScrollListener() {
private var prevFirst = NO_POSITION
private var prevLast = NO_POSITION
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
invalidate(recyclerView)
}
fun invalidate(recyclerView: RecyclerView) {
val lm = recyclerView.layoutManager as? LinearLayoutManager ?: return
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
if (first == prevFirst && last == prevLast) {
return
}
prevFirst = first
prevLast = last
if (first == NO_POSITION || last == NO_POSITION) {
return
}
for (child in recyclerView.children) {
val wh = recyclerView.getChildViewHolder(child) ?: continue
(wh as? LifecycleAwareViewHolder)?.setIsCurrent(wh.absoluteAdapterPosition in first..last)
}
}
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import androidx.collection.ArrayMap
import androidx.collection.ArraySet
import org.koitharu.kotatsu.BuildConfig
import java.util.Collections
import java.util.EnumSet
@@ -57,3 +58,13 @@ inline fun <reified E : Enum<E>> Collection<E>.toEnumSet(): EnumSet<E> = if (isE
}
fun <E : Enum<E>> Collection<E>.sortedByOrdinal() = sortedBy { it.ordinal }
fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try {
sortedWith(comparator)
} catch (e: IllegalArgumentException) {
if (BuildConfig.DEBUG) {
throw e
} else {
toList()
}
}

View File

@@ -7,7 +7,6 @@ import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.provider.OpenableColumns
import androidx.annotation.WorkerThread
import androidx.core.database.getStringOrNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
@@ -19,7 +18,9 @@ import java.io.FileFilter
import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.readAttributes
import kotlin.io.path.walk
fun File.subdir(name: String) = File(this, name).also {
if (!it.exists()) it.mkdirs()
@@ -49,7 +50,7 @@ fun File.getStorageName(context: Context): String = runCatching {
}
}.getOrNull() ?: context.getString(R.string.other_storage)
fun Uri.toFileOrNull() = if (scheme == "file") path?.let(::File) else null
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
delete() || deleteRecursively()
@@ -71,31 +72,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
}
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
computeSizeInternal(this)
}
@WorkerThread
private fun computeSizeInternal(file: File): Long {
return if (file.isDirectory) {
file.children().sumOf { computeSizeInternal(it) }
} else {
file.length()
}
}
fun File.listFilesRecursive(filter: FileFilter? = null): Sequence<File> = sequence {
listFilesRecursiveImpl(this@listFilesRecursive, filter)
}
private suspend fun SequenceScope<File>.listFilesRecursiveImpl(root: File, filter: FileFilter?) {
val ss = root.children()
for (f in ss) {
if (f.isDirectory) {
listFilesRecursiveImpl(f, filter)
} else if (filter == null || filter.accept(f)) {
yield(f)
}
}
walkCompat().sumOf { it.length() }
}
fun File.children() = FileSequence(this)
@@ -108,3 +85,12 @@ val File.creationTime
} else {
lastModified()
}
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }
} else {
// Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile }
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import androidx.core.os.LocaleListCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
operator fun LocaleListCompat.iterator(): ListIterator<Locale> = LocaleListCompatIterator(this)
@@ -17,6 +20,14 @@ inline fun <T> LocaleListCompat.mapToSet(block: (Locale) -> T): Set<T> {
fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException()
fun String?.getLocaleDisplayName(context: Context): String {
if (this == null) {
return context.getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator<Locale> {
private var index = 0

View File

@@ -27,7 +27,10 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException
private const val MSG_NO_SPACE_LEFT = "No space left on device"
private const val IMAGE_FORMAT_NO_SUPPORTED = "Image format not supported"
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
@@ -56,8 +59,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is HttpException -> getHttpDisplayMessage(response.code, resources)
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
is IOException -> getDisplayMessage(message, resources) ?: localizedMessage
else -> localizedMessage
else -> getDisplayMessage(message, resources) ?: localizedMessage
}.ifNullOrEmpty {
resources.getString(R.string.error_occurred)
}
@@ -82,7 +84,10 @@ private fun getHttpDisplayMessage(statusCode: Int, resources: Resources): String
private fun getDisplayMessage(msg: String?, resources: Resources): String? = when {
msg.isNullOrEmpty() -> null
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
msg.contains(IMAGE_FORMAT_NO_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
else -> null
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.core.util.ext
import android.net.Uri
import androidx.core.net.toFile
import okio.Source
import okio.source
import okio.use
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import java.io.File
import java.util.zip.ZipFile
const val URI_SCHEME_FILE = "file"
const val URI_SCHEME_ZIP = "file+zip"
fun Uri.exists(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().exists()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { it.getEntry(fragment) != null }
}
else -> unsupportedUri(this)
}
fun Uri.isTargetNotEmpty(): Boolean = when (scheme) {
URI_SCHEME_FILE -> toFile().isNotEmpty()
URI_SCHEME_ZIP -> {
val file = File(requireNotNull(schemeSpecificPart))
file.exists() && ZipFile(file).use { (it.getEntry(fragment)?.size ?: 0L) != 0L }
}
else -> unsupportedUri(this)
}
fun Uri.source(): Source = when (scheme) {
URI_SCHEME_FILE -> toFile().source()
URI_SCHEME_ZIP -> {
val zip = ZipFile(schemeSpecificPart)
val entry = zip.getEntry(fragment)
zip.getInputStream(entry).source().withExtraCloseable(zip)
}
else -> unsupportedUri(this)
}
fun File.toZipUri(entryName: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryName")
private fun unsupportedUri(uri: Uri): Nothing {
throw IllegalArgumentException("Bad uri $uri: only schemes $URI_SCHEME_FILE and $URI_SCHEME_ZIP are supported")
}

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.core.zip
import android.net.Uri
import androidx.annotation.WorkerThread
import androidx.collection.LruCache
import okhttp3.internal.closeQuietly
import okio.Source
import okio.source
import java.io.File
import java.util.zip.ZipFile
class ZipPool(maxSize: Int) : LruCache<String, ZipFile>(maxSize) {
override fun entryRemoved(evicted: Boolean, key: String, oldValue: ZipFile, newValue: ZipFile?) {
super.entryRemoved(evicted, key, oldValue, newValue)
oldValue.closeQuietly()
}
override fun create(key: String): ZipFile {
return ZipFile(File(key), ZipFile.OPEN_READ)
}
@Synchronized
@WorkerThread
operator fun get(uri: Uri): Source {
val zip = requireNotNull(get(uri.schemeSpecificPart)) {
"Cannot obtain zip by \"$uri\""
}
val entry = zip.getEntry(uri.fragment)
return zip.getInputStream(entry).source()
}
}

View File

@@ -11,8 +11,8 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -33,7 +33,6 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
private val networkState: NetworkState,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
@@ -48,16 +47,15 @@ class DetailsLoadUseCase @Inject constructor(
null
}
send(MangaDetails(manga, null, null, false))
if (!networkState.value) {
// try load offline instead
local?.await()?.manga?.let { localManga ->
try {
val details = getDetails(manga)
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) {
local?.await()?.manga?.also { localManga ->
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
return@channelFlow
}
} ?: throw e
}
val details = getDetails(manga)
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
}
private suspend fun getDetails(seed: Manga) = runCatchingCancellable {

View File

@@ -197,6 +197,11 @@ class DetailsFragment :
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_abandoned)
}
MangaState.PAUSED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_paused)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_action_pause)
}
null -> infoLayout.textViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL) {

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.suggestions.domain.TagsBlacklist
@@ -73,7 +74,15 @@ class ExploreRepository @Inject constructor(
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, 0.4f) }
}
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList()
val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = emptySet(),
),
).asArrayList()
if (settings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }
}

View File

@@ -5,6 +5,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -18,7 +19,7 @@ class RecoverMangaUseCase @Inject constructor(
return@runCatchingCancellable null
}
val repository = repositoryFactory.create(manga.source)
val list = repository.getList(offset = 0, query = manga.title)
val list = repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
val newManga = list.find { x -> x.title == manga.title }?.let {
repository.getDetails(it)
} ?: return@runCatchingCancellable null

View File

@@ -19,6 +19,8 @@ class FilterAdapter(
init {
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())

View File

@@ -4,6 +4,7 @@ import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
@@ -27,14 +28,48 @@ fun filterSortDelegate(
}
}
fun filterTagDelegate(
fun filterStateDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item)
listener.onStateItemClick(item)
}
bind { payloads ->
binding.root.setText(item.state.titleResId)
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item, isFromChip = false)
}
bind { payloads ->
binding.root.text = item.tag.title
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
}
}
fun filterTagMultipleDelegate(
listener: OnFilterChangedListener,
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
) {
itemView.setOnClickListener {
listener.onTagItemClick(item, isFromChip = false)
}
bind { payloads ->

View File

@@ -28,11 +28,11 @@ import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
import org.koitharu.kotatsu.filter.ui.model.FilterItem
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -55,7 +55,8 @@ class FilterCoordinator @Inject constructor(
private val coroutineScope = lifecycle.lifecycleScope
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
private val currentState = MutableStateFlow(FilterState(repository.defaultSortOrder, emptySet()))
private val currentState =
MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()))
private var searchQuery = MutableStateFlow("")
private val localTags = SuspendLazy {
dataRepository.findTags(repository.source)
@@ -68,7 +69,12 @@ class FilterCoordinator @Inject constructor(
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
scope = coroutineScope + Dispatchers.Default,
started = SharingStarted.Lazily,
initialValue = FilterHeaderModel(emptyList(), repository.defaultSortOrder, false),
initialValue = FilterHeaderModel(
chips = emptyList(),
sortOrder = repository.defaultSortOrder,
hasSelectedTags = false,
allowMultipleTags = repository.isMultipleTagsSupported,
),
)
init {
@@ -81,24 +87,48 @@ class FilterCoordinator @Inject constructor(
override fun onSortItemClick(item: FilterItem.Sort) {
currentState.update { oldValue ->
FilterState(item.order, oldValue.tags)
oldValue.copy(sortOrder = item.order)
}
repository.defaultSortOrder = item.order
}
override fun onTagItemClick(item: FilterItem.Tag) {
override fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) {
currentState.update { oldValue ->
val newTags = if (item.isChecked) {
val newTags = if (!item.isMultiple) {
if (isFromChip && item.isChecked) {
emptySet()
} else {
setOf(item.tag)
}
} else if (item.isChecked) {
oldValue.tags - item.tag
} else {
oldValue.tags + item.tag
}
FilterState(oldValue.sortOrder, newTags)
oldValue.copy(tags = newTags)
}
}
override fun onStateItemClick(item: FilterItem.State) {
currentState.update { oldValue ->
val newStates = if (item.isChecked) {
oldValue.states - item.state
} else {
oldValue.states + item.state
}
oldValue.copy(states = newStates)
}
}
override fun onListHeaderClick(item: ListHeader, view: View) {
reset()
currentState.update { oldValue ->
oldValue.copy(
sortOrder = oldValue.sortOrder,
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
locale = null,
states = if (item.payload == R.string.state) emptySet() else oldValue.states,
)
}
}
fun observeAvailableTags(): Flow<Set<MangaTag>?> = flow {
@@ -112,13 +142,13 @@ class FilterCoordinator @Inject constructor(
fun setTags(tags: Set<MangaTag>) {
currentState.update { oldValue ->
FilterState(oldValue.sortOrder, tags)
oldValue.copy(tags = tags)
}
}
fun reset() {
currentState.update { oldValue ->
FilterState(oldValue.sortOrder, emptySet())
oldValue.copy(oldValue.sortOrder, emptySet(), null, emptySet())
}
}
@@ -133,7 +163,12 @@ class FilterCoordinator @Inject constructor(
observeAvailableTags(),
) { state, available ->
val chips = createChipsList(state, available.orEmpty(), 8)
FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty())
FilterHeaderModel(
chips = chips,
sortOrder = state.sortOrder,
hasSelectedTags = state.tags.isNotEmpty(),
allowMultipleTags = repository.isMultipleTagsSupported,
)
}
private fun getItemsFlow() = combine(
@@ -156,7 +191,7 @@ class FilterCoordinator @Inject constructor(
}
private suspend fun createChipsList(
filterState: FilterState,
filterState: MangaListFilter.Advanced,
availableTags: Set<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> {
@@ -205,12 +240,14 @@ class FilterCoordinator @Inject constructor(
@WorkerThread
private fun buildFilterList(
allTags: TagsWrapper,
state: FilterState,
state: MangaListFilter.Advanced,
query: String,
): List<ListModel> {
val sortOrders = repository.sortOrders.sortedByOrdinal()
val states = repository.states
val tags = mergeTags(state.tags, allTags.tags).toList()
val list = ArrayList<ListModel>(tags.size + sortOrders.size + 3)
val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
val isMultiTag = repository.isMultipleTagsSupported
if (query.isEmpty()) {
if (sortOrders.isNotEmpty()) {
list.add(ListHeader(R.string.sort_order))
@@ -218,10 +255,28 @@ class FilterCoordinator @Inject constructor(
FilterItem.Sort(it, isSelected = it == state.sortOrder)
}
}
if (states.isNotEmpty()) {
list.add(
ListHeader(
textRes = R.string.state,
buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset,
payload = R.string.state,
),
)
states.mapTo(list) {
FilterItem.State(it, isChecked = it in state.states)
}
}
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
list.add(ListHeader(R.string.genres, if (state.tags.isEmpty()) 0 else R.string.reset))
list.add(
ListHeader(
textRes = R.string.genres,
buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset,
payload = R.string.genres,
),
)
tags.mapTo(list) {
FilterItem.Tag(it, isChecked = it in state.tags)
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
}
}
if (allTags.isError) {
@@ -232,7 +287,7 @@ class FilterCoordinator @Inject constructor(
} else {
tags.mapNotNullTo(list) {
if (it.title.contains(query, ignoreCase = true)) {
FilterItem.Tag(it, isChecked = it in state.tags)
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
} else {
null
}

View File

@@ -39,7 +39,8 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
if (tag == null) {
FilterSheetFragment.show(parentFragmentManager)
} else {
filter.onTagItemClick(FilterItem.Tag(tag, !chip.isChecked))
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked)
filter.onTagItemClick(filterItem, isFromChip = true)
}
}

View File

@@ -7,5 +7,7 @@ interface OnFilterChangedListener : ListHeaderClickListener {
fun onSortItemClick(item: FilterItem.Sort)
fun onTagItemClick(item: FilterItem.Tag)
fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean)
fun onStateItemClick(item: FilterItem.State)
}

View File

@@ -7,6 +7,7 @@ class FilterHeaderModel(
val chips: Collection<ChipsView.ChipModel>,
val sortOrder: SortOrder?,
val hasSelectedTags: Boolean,
val allowMultipleTags: Boolean,
) {
val textSummary: String
@@ -19,6 +20,7 @@ class FilterHeaderModel(
other as FilterHeaderModel
if (chips != other.chips) return false
if (allowMultipleTags != other.allowMultipleTags) return false
return sortOrder == other.sortOrder
// Not need to check hasSelectedTags
@@ -26,6 +28,7 @@ class FilterHeaderModel(
override fun hashCode(): Int {
var result = chips.hashCode()
result = 31 * result + allowMultipleTags.hashCode()
result = 31 * result + (sortOrder?.hashCode() ?: 0)
return result
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.filter.ui.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -28,11 +29,12 @@ sealed interface FilterItem : ListModel {
data class Tag(
val tag: MangaTag,
val isMultiple: Boolean,
val isChecked: Boolean,
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is Tag && other.tag == tag
return other is Tag && other.isMultiple == isMultiple && other.tag == tag
}
override fun getChangePayload(previousState: ListModel): Any? {
@@ -44,6 +46,24 @@ sealed interface FilterItem : ListModel {
}
}
data class State(
val state: MangaState,
val isChecked: Boolean
) : FilterItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is State && other.state == state
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is State && previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}
data class Error(
@StringRes val textResId: Int,
) : FilterItem {

View File

@@ -1,9 +0,0 @@
package org.koitharu.kotatsu.filter.ui.model
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
data class FilterState(
val sortOrder: SortOrder?,
val tags: Set<MangaTag>,
)

View File

@@ -4,6 +4,8 @@ enum class ListItemType {
FILTER_SORT,
FILTER_TAG,
FILTER_TAG_MULTI,
FILTER_STATE,
HEADER,
MANGA_LIST,
MANGA_LIST_DETAILED,

View File

@@ -29,6 +29,8 @@ class TypedListSpacingDecoration(
when (itemType) {
ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG,
ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE,
-> outRect.set(0)
ListItemType.HEADER,

View File

@@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@HiltViewModel
@@ -54,7 +55,7 @@ class ListConfigViewModel @Inject constructor(
}?.sortedByOrdinal()
fun getSelectedSortOrder(): ListSortOrder? = when (section) {
is ListConfigSection.Favorites -> runBlocking { favouritesRepository.getCategory(section.categoryId).order }
is ListConfigSection.Favorites -> getCategorySortOrder(section.categoryId)
ListConfigSection.General -> null
ListConfigSection.History -> settings.historySortOrder
ListConfigSection.Suggestions -> ListSortOrder.RELEVANCE // TODO
@@ -73,4 +74,10 @@ class ListConfigViewModel @Inject constructor(
ListConfigSection.Suggestions -> Unit
}
}
private fun getCategorySortOrder(id: Long): ListSortOrder = runBlocking {
runCatchingCancellable {
favouritesRepository.getCategory(id).order
}.getOrDefault(ListSortOrder.NEWEST)
}
}

View File

@@ -98,7 +98,8 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
if (filter == null) {
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
} else {
filter.onTagItemClick(FilterItem.Tag(tag, false))
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false)
filter.onTagItemClick(filterItem, isFromChip = false)
closeSelf()
}
}

View File

@@ -1,31 +1,20 @@
package org.koitharu.kotatsu.local.data
import android.net.Uri
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
class CbzFilter : FileFilter, FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name)
}
override fun accept(pathname: File?): Boolean {
return isFileSupported(pathname?.name ?: return false)
}
companion object {
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
fun isUriSupported(uri: Uri): Boolean {
val scheme = uri.scheme?.lowercase(Locale.ROOT)
return scheme != null && scheme == "cbz" || scheme == "zip"
}
}
private fun isCbzExtension(ext: String?): Boolean {
return ext.equals("cbz", ignoreCase = true) || ext.equals("zip", ignoreCase = true)
}
fun hasCbzExtension(string: String): Boolean {
val ext = string.substringAfterLast('.', "")
return isCbzExtension(ext)
}
fun File.hasCbzExtension() = isCbzExtension(extension)
fun Uri.isZipUri() = scheme.let {
it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
}

View File

@@ -1,29 +1,11 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FileFilter
import java.io.FilenameFilter
import java.util.Locale
import java.util.zip.ZipEntry
class ImageFileFilter : FilenameFilter, FileFilter {
override fun accept(dir: File, name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
override fun accept(pathname: File?): Boolean {
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
return isExtensionValid(ext)
}
fun accept(entry: ZipEntry): Boolean {
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return isExtensionValid(ext)
}
fun isExtensionValid(ext: String): Boolean {
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
}
fun hasImageExtension(string: String): Boolean {
val ext = string.substringAfterLast('.', "")
return ext.equals("png", ignoreCase = true) || ext.equals("jpg", ignoreCase = true)
|| ext.equals("jpeg", ignoreCase = true) || ext.equals("webp", ignoreCase = true)
}
fun hasImageExtension(file: File) = hasImageExtension(file.name)

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.CompositeMutex2
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.deleteAwait
@@ -25,8 +26,10 @@ import org.koitharu.kotatsu.local.data.output.LocalMangaUtil
import org.koitharu.kotatsu.local.domain.model.LocalManga
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 org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -47,7 +50,9 @@ class LocalMangaRepository @Inject constructor(
override val source = MangaSource.LOCAL
private val locks = CompositeMutex2<Long>()
override val isMultipleTagsSupported: Boolean = true
override val sortOrders: Set<SortOrder> = EnumSet.of(SortOrder.ALPHABETICAL, SortOrder.RATING, SortOrder.NEWEST)
override val states = emptySet<MangaState>()
override var defaultSortOrder: SortOrder
get() = settings.localListOrder
@@ -55,33 +60,32 @@ class LocalMangaRepository @Inject constructor(
settings.localListOrder = value
}
override suspend fun getList(offset: Int, query: String): List<Manga> {
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
if (query.isNotEmpty()) {
list.retainAll { x -> x.isMatchesQuery(query) }
}
return list.unwrap()
}
when (filter) {
is MangaListFilter.Search -> {
list.retainAll { x -> x.isMatchesQuery(filter.query) }
}
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
if (offset > 0) {
return emptyList()
}
val list = getRawList()
if (!tags.isNullOrEmpty()) {
list.retainAll { x -> x.containsTags(tags) }
}
when (sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(org.koitharu.kotatsu.core.util.AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
is MangaListFilter.Advanced -> {
if (filter.tags.isNotEmpty()) {
list.retainAll { x -> x.containsTags(filter.tags) }
}
when (filter.sortOrder) {
SortOrder.ALPHABETICAL -> list.sortWith(compareBy(AlphanumComparator()) { x -> x.manga.title })
SortOrder.RATING -> list.sortByDescending { it.manga.rating }
SortOrder.NEWEST,
SortOrder.UPDATED,
-> list.sortByDescending { it.createdAt }
else -> Unit
else -> Unit
}
}
null -> Unit
}
return list.unwrap()
}

View File

@@ -14,6 +14,7 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.Cache
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_FILE
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.getStorageName
import org.koitharu.kotatsu.core.util.ext.resolveFile
@@ -84,7 +85,7 @@ class LocalStorageManager @Inject constructor(
}
suspend fun resolveUri(uri: Uri): File? = runInterruptible(Dispatchers.IO) {
if (uri.scheme == "file") {
if (uri.scheme == URI_SCHEME_FILE) {
uri.toFile()
} else {
uri.resolveFile(context)

View File

@@ -15,9 +15,9 @@ import okio.source
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.util.ext.resolveName
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import java.io.File
@@ -46,7 +46,7 @@ class SingleMangaImporter @Inject constructor(
private suspend fun importFile(uri: Uri): LocalManga = withContext(Dispatchers.IO) {
val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!CbzFilter.isFileSupported(name)) {
if (!hasCbzExtension(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = File(getOutputDir(), name)

View File

@@ -6,12 +6,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.listFilesRecursive
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.ImageFileFilter
import org.koitharu.kotatsu.core.util.ext.walkCompat
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.data.hasImageExtension
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
@@ -91,16 +91,12 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.listFilesRecursive(ImageFileFilter())
file.walkCompat()
.filter { hasImageExtension(it) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
MangaPage(
id = pageUri.longHashCode(),
url = pageUri,
preview = null,
source = MangaSource.LOCAL,
)
MangaPage(pageUri.longHashCode(), pageUri, null, MangaSource.LOCAL)
}
} else {
ZipFile(file).use { zip ->
@@ -124,20 +120,20 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
private fun getChaptersFiles(): List<File> = root.walkCompat()
.filter { it.hasCbzExtension() }
.toListSorted(compareBy(AlphanumComparator()) { it.name })
private fun findFirstImageEntry(): String? {
val filter = ImageFileFilter()
root.listFilesRecursive(filter).firstOrNull()?.let {
return it.toUri().toString()
}
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
return ZipFile(cbz).use { zip ->
zip.entries().asSequence()
.firstOrNull { x -> !x.isDirectory && filter.accept(x) }
?.let { entry -> zipUri(cbz, entry.name) }
}
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
?: run {
val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null
ZipFile(cbz).use { zip ->
zip.entries().asSequence()
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
?.let { zipUri(cbz, it.name) }
}
}
}
private fun fileUri(base: File, name: String): String {

View File

@@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.hasCbzExtension
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -39,7 +39,7 @@ sealed class LocalMangaInput(
fun ofOrNull(file: File): LocalMangaInput? = when {
file.isDirectory -> LocalMangaDirInput(file)
CbzFilter.isFileSupported(file.name) -> LocalMangaZipInput(file)
hasCbzExtension(file.name) -> LocalMangaZipInput(file)
else -> null
}

View File

@@ -1,11 +1,11 @@
package org.koitharu.kotatsu.local.domain
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.IOException
import javax.inject.Inject
@@ -27,7 +27,7 @@ class DeleteLocalMangaUseCase @Inject constructor(
}
suspend operator fun invoke(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null, null)
val list = localMangaRepository.getList(0, null)
var removed = 0
for (manga in list) {
if (manga.id in ids) {

View File

@@ -7,6 +7,7 @@ import android.net.Uri
import androidx.annotation.AnyThread
import androidx.collection.LongSparseArray
import androidx.collection.set
import androidx.core.net.toUri
import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.lifecycle.RetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -33,17 +34,18 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.ensureSuccess
import org.koitharu.kotatsu.core.util.ext.exists
import org.koitharu.kotatsu.core.util.ext.getCompletionResultOrNull
import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isTargetNotEmpty
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.core.zip.ZipPool
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@@ -71,13 +73,12 @@ class PageLoader @Inject constructor(
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
private val semaphore = Semaphore(3)
private val convertLock = Mutex()
private val prefetchLock = Mutex()
private var repository: MangaRepository? = null
private val prefetchQueue = LinkedList<MangaPage>()
private val zipPool = ZipPool(2)
private val counter = AtomicInteger(0)
private var prefetchQueueLimit = PREFETCH_LIMIT_DEFAULT // TODO adaptive
@@ -85,7 +86,6 @@ class PageLoader @Inject constructor(
synchronized(tasks) {
tasks.clear()
}
zipPool.evictAll()
}
fun isPrefetchApplicable(): Boolean {
@@ -113,7 +113,7 @@ class PageLoader @Inject constructor(
}
}
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<File, Float> {
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
var task = tasks[page.id]?.takeIf { it.isValid() }
if (force) {
task?.cancel()
@@ -127,7 +127,7 @@ class PageLoader @Inject constructor(
return task
}
suspend fun loadPage(page: MangaPage, force: Boolean): File {
suspend fun loadPage(page: MangaPage, force: Boolean): Uri {
return loadPageAsync(page, force).await()
}
@@ -167,11 +167,11 @@ class PageLoader @Inject constructor(
}
}
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<File, Float> {
private fun loadPageAsyncImpl(page: MangaPage, skipCache: Boolean): ProgressDeferred<Uri, Float> {
val progress = MutableStateFlow(PROGRESS_UNDEFINED)
val deferred = loaderScope.async {
if (!skipCache) {
cache.get(page.url)?.let { return@async it }
cache.get(page.url)?.let { return@async it.toUri() }
}
counter.incrementAndGet()
try {
@@ -195,26 +195,24 @@ class PageLoader @Inject constructor(
}
}
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): File = semaphore.withPermit {
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
val uri = Uri.parse(pageUrl)
return if (CbzFilter.isUriSupported(uri)) {
runInterruptible(Dispatchers.IO) {
zipPool[uri]
}.use {
cache.put(pageUrl, it)
return if (uri.isZipUri()) {
if (uri.scheme == URI_SCHEME_ZIP) {
uri
} else { // legacy uri
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
}
} else {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) {
"Null response"
}
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
cache.put(pageUrl, it.source())
}
}
}.toUri()
}
}
@@ -222,9 +220,9 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
}
private fun Deferred<File>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { file ->
file.exists() && file.isNotEmpty()
private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty()
}?.getOrDefault(false) ?: true
}

View File

@@ -15,7 +15,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException
import okio.buffer
import okio.sink
import okio.source
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
@@ -41,8 +42,8 @@ class PageSaveHelper @Inject constructor(
saveLauncher: ActivityResultLauncher<String>,
): Uri {
val pageUrl = pageLoader.getPageUrl(page)
val pageFile = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageFile)
val pageUri = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageUri)
val destination = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
continuation = cont
@@ -54,7 +55,7 @@ class PageSaveHelper @Inject constructor(
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.sink()?.buffer()
}?.use { output ->
pageFile.source().use { input ->
pageUri.source().use { input ->
output.writeAllCancellable(input)
}
} ?: throw IOException("Output stream is null")
@@ -65,7 +66,7 @@ class PageSaveHelper @Inject constructor(
resume(uri)
} != null
private suspend fun getProposedFileName(url: String, file: File): String {
private suspend fun getProposedFileName(url: String, fileUri: Uri): String {
var name = if (url.startsWith("cbz://")) {
requireNotNull(url.toUri().fragment)
} else {
@@ -74,7 +75,7 @@ class PageSaveHelper @Inject constructor(
var extension = name.substringAfterLast('.', "")
name = name.substringBeforeLast('.')
if (extension.length !in 2..4) {
val mimeType = getImageMimeType(file)
val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) }
extension = if (mimeType != null) {
MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK
} else {

View File

@@ -27,7 +27,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
@@ -119,9 +118,12 @@ class ReaderViewModel @Inject constructor(
valueProducer = { isReaderKeepScreenOn },
)
val isWebtoonZooEnabled = observeIsWebtoonZoomEnabled()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val isZoomControlsEnabled = getObserveIsZoomControlEnabled().flatMapLatest { zoom ->
if (zoom) {
combine(readerMode, observeIsWebtoonZoomEnabled()) { mode, ze -> ze || mode != ReaderMode.WEBTOON }
combine(readerMode, isWebtoonZooEnabled) { mode, ze -> ze || mode != ReaderMode.WEBTOON }
} else {
flowOf(false)
}
@@ -260,8 +262,8 @@ class ReaderViewModel @Inject constructor(
stateChangeJob = launchJob(Dispatchers.Default) {
prevJob?.cancelAndJoin()
loadingJob?.join()
if (BuildConfig.DEBUG && pages.size != content.value.pages.size) {
throw IllegalStateException("Concurrent pages modification")
if (pages.size != content.value.pages.size) {
return@launchJob // TODO
}
pages.getOrNull(position)?.let { page ->
currentState.update { cs ->

View File

@@ -36,6 +36,9 @@ class ReaderSettings(
val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value?.takeUnless { it.isEmpty }
val isReaderOptimizationEnabled: Boolean
get() = settings.isReaderOptimizationEnabled
val bitmapConfig: Bitmap.Config
get() = if (settings.is32BitColorsEnabled) {
Bitmap.Config.ARGB_8888
@@ -104,7 +107,8 @@ class ReaderSettings(
key == AppSettings.KEY_ZOOM_MODE ||
key == AppSettings.KEY_PAGES_NUMBERS ||
key == AppSettings.KEY_READER_BACKGROUND ||
key == AppSettings.KEY_32BIT_COLOR
key == AppSettings.KEY_32BIT_COLOR ||
key == AppSettings.KEY_READER_OPTIMIZE
) {
notifyChanged()
}

View File

@@ -2,13 +2,16 @@ package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context
import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
@@ -16,7 +19,8 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
@@ -43,6 +47,13 @@ abstract class BasePageHolder<B : ViewBinding>(
protected abstract fun onBind(data: ReaderPage)
override fun onResume() {
super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) {
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
}
}
@CallSuper
open fun onAttachedToWindow() {
delegate.onAttachedToWindow()
@@ -57,4 +68,10 @@ abstract class BasePageHolder<B : ViewBinding>(
open fun onRecycled() {
delegate.onRecycle()
}
protected fun getBackgroundDownsampling() = when {
!settings.isReaderOptimizationEnabled -> 1
context.isLowRamDevice() -> 8
else -> 4
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.net.Uri
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.Observer
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
@@ -20,10 +21,10 @@ import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.File
import java.io.IOException
class PageHolderDelegate(
@@ -35,15 +36,20 @@ class PageHolderDelegate(
) : DefaultOnImageEventListener, Observer<ReaderSettings> {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
private var state = State.EMPTY
var state = State.EMPTY
private set
private var job: Job? = null
private var file: File? = null
private var uri: Uri? = null
private var error: Throwable? = null
init {
callback.onConfigChanged()
scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
callback.onConfigChanged()
}
}
fun isLoading() = job?.isActive == true
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch {
@@ -52,12 +58,15 @@ class PageHolderDelegate(
}
}
fun retry(page: MangaPage) {
fun retry(page: MangaPage, isFromUser: Boolean) {
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = error
if (e != null && ExceptionResolver.canResolve(e)) {
if (!isFromUser) {
return@launch
}
exceptionResolver.resolve(e)
}
doLoad(page, force = true)
@@ -79,15 +88,15 @@ class PageHolderDelegate(
fun onRecycle() {
state = State.EMPTY
file = null
uri = null
error = null
job?.cancel()
}
fun reload() {
if (state == State.SHOWN ) {
file?.let {
callback.onImageReady(it.toUri())
if (state == State.SHOWN) {
uri?.let {
callback.onImageReady(it)
}
}
}
@@ -106,10 +115,10 @@ class PageHolderDelegate(
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
val file = this.file
val uri = this.uri
error = e
if (state == State.LOADED && e is IOException && file != null && file.exists()) {
tryConvert(file, e)
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(uri, e)
} else {
state = State.ERROR
callback.onError(e)
@@ -123,12 +132,13 @@ class PageHolderDelegate(
callback.onConfigChanged()
}
private fun tryConvert(file: File, e: Exception) {
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
try {
val file = uri.toFile()
loader.convertInPlace(file)
state = State.CONVERTED
callback.onImageReady(file.toUri())
@@ -149,14 +159,14 @@ class PageHolderDelegate(
yield()
try {
val task = loader.loadPageAsync(data, force)
file = coroutineScope {
uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancelAndJoin()
file
}
state = State.LOADED
callback.onImageReady(checkNotNull(file).toUri())
callback.onImageReady(checkNotNull(uri))
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
@@ -166,7 +176,7 @@ class PageHolderDelegate(
callback.onError(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data)
retry(data, isFromUser = false)
}
}
}
@@ -176,7 +186,7 @@ class PageHolderDelegate(
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
private enum class State {
enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}

View File

@@ -11,12 +11,13 @@ import android.view.ViewGroup
import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
@@ -46,6 +47,8 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
@Inject
lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -62,6 +65,9 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
}
viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -80,6 +86,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
}
override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null
super.onDestroyView()
}
@@ -132,15 +139,16 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val reversedPages = pages.asReversed()
val items = async {
val items = launch {
requireAdapter().setItems(reversedPages)
yield()
pagerLifecycleDispatcher?.invalidate()
}
if (pendingState != null) {
val position = reversedPages.indexOfLast {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.await()
items.join()
if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position)
@@ -149,7 +157,7 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
.show()
}
} else {
items.await()
items.join()
}
}

View File

@@ -30,7 +30,7 @@ open class PageHolder(
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener,
ZoomControl.ZoomControlListener {
@@ -45,12 +45,22 @@ open class PageHolder(
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
override fun onResume() {
super.onResume()
binding.ssiv.downsampling = 1
}
override fun onPause() {
super.onPause()
binding.ssiv.downsampling = getBackgroundDownsampling()
}
override fun onConfigChanged() {
super.onConfigChanged()
@Suppress("SENSELESS_COMPARISON")
if (settings.applyBitmapConfig(binding.ssiv) && delegate != null) {
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
}
@SuppressLint("SetTextI18n")
@@ -129,7 +139,7 @@ open class PageHolder(
final override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}

View File

@@ -11,12 +11,13 @@ import android.view.ViewGroup
import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
@@ -42,6 +43,8 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
@Inject
lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -61,6 +64,9 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
}
viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -79,6 +85,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
}
override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null
super.onDestroyView()
}
@@ -107,15 +114,16 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) =
coroutineScope {
val items = async {
val items = launch {
requireAdapter().setItems(pages)
yield()
pagerLifecycleDispatcher?.invalidate()
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.await()
items.join()
if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position)
@@ -124,7 +132,7 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
.show()
}
} else {
items.await()
items.join()
}
}

View File

@@ -25,7 +25,7 @@ class WebtoonHolder(
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver),
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener {
private var scrollToRestore = 0
@@ -40,10 +40,20 @@ class WebtoonHolder(
override fun onConfigChanged() {
super.onConfigChanged()
@Suppress("SENSELESS_COMPARISON")
if (settings.applyBitmapConfig(binding.ssiv) && delegate != null) {
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
}
// FIXME binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
}
override fun onResume() {
super.onResume()
binding.ssiv.downsampling = 1
}
override fun onPause() {
super.onPause()
// FIXME binding.ssiv.downsampling = getBackgroundDownsampling()
}
override fun onBind(data: ReaderPage) {
@@ -107,7 +117,7 @@ class WebtoonHolder(
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return)
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}

View File

@@ -6,13 +6,15 @@ import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.RecyclerViewLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
@@ -32,6 +34,8 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
private val scrollInterpolator = DecelerateInterpolator()
private var recyclerLifecycleDispatcher: RecyclerViewLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -43,10 +47,17 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
setHasFixedSize(true)
adapter = readerAdapter
addOnPageScrollListener(PageScrollListener())
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it)
}
}
viewModel.isWebtoonZooEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it
}
}
override fun onDestroyView() {
recyclerLifecycleDispatcher = null
requireViewBinding().recyclerView.adapter = null
super.onDestroyView()
}
@@ -60,15 +71,18 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
)
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val setItems = async {
val setItems = launch {
requireAdapter().setItems(pages)
yield()
viewBinding?.recyclerView?.let { rv ->
recyclerLifecycleDispatcher?.invalidate(rv)
}
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
setItems.await()
setItems.join()
if (position != -1) {
with(requireViewBinding().recyclerView) {
firstVisibleItemPosition = position
@@ -83,7 +97,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
.show()
}
} else {
setItems.await()
setItems.join()
}
}

View File

@@ -51,7 +51,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
private val targetHitRect = Rect()
private var animator: ValueAnimator? = null
var isZoomEnable = true
var isZoomEnable = false
set(value) {
field = value
if (scale != 1f) {

View File

@@ -19,8 +19,8 @@ import okio.source
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType
@@ -56,7 +56,7 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri()
return if (CbzFilter.isUriSupported(uri)) {
return if (uri.isZipUri()) {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)

View File

@@ -30,7 +30,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.MangaFilter
import org.koitharu.kotatsu.filter.ui.model.FilterState
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -40,6 +39,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import javax.inject.Inject
@@ -122,7 +122,7 @@ open class RemoteListViewModel @Inject constructor(
applyFilter(tags)
}
protected fun loadList(filterState: FilterState, append: Boolean): Job {
protected fun loadList(filterState: MangaListFilter.Advanced, append: Boolean): Job {
loadingJob?.let {
if (it.isActive) return it
}
@@ -131,8 +131,7 @@ open class RemoteListViewModel @Inject constructor(
listError.value = null
val list = repository.getList(
offset = if (append) mangaList.value?.size ?: 0 else 0,
sortOrder = filterState.sortOrder,
tags = filterState.tags,
filter = filterState,
)
val oldList = mangaList.getAndUpdate { oldList ->
if (!append || oldList.isNullOrEmpty()) {

View File

@@ -26,6 +26,7 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import javax.inject.Inject
@HiltViewModel
@@ -101,7 +102,7 @@ class SearchViewModel @Inject constructor(
listError.value = null
val list = repository.getList(
offset = if (append) mangaList.value?.size ?: 0 else 0,
query = query,
filter = MangaListFilter.Search(query)
)
if (!append) {
mangaList.value = list

View File

@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -115,7 +116,7 @@ class MultiSearchViewModel @Inject constructor(
launch {
val item = runCatchingCancellable {
semaphore.withPermit {
mangaRepositoryFactory.create(source).getList(offset = 0, query = q)
mangaRepositoryFactory.create(source).getList(offset = 0, filter = MangaListFilter.Search(q))
.toUi(ListMode.GRID, extraProvider)
}
}.fold(

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.getLocalesConfig
import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
@@ -107,7 +108,7 @@ class AppearanceSettingsFragment :
private fun initLocalePicker(preference: ListPreference) {
val locales = preference.context.getLocalesConfig()
.toList()
.sortedWith(LocaleComparator())
.sortedWithSafe(LocaleComparator())
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.mapToSet
import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
@@ -78,7 +79,7 @@ class OnboardViewModel @Inject constructor(
summary = srcs.joinToString { it.title },
isChecked = key in selectedLocales,
)
}.sortedWith(SourceLocaleComparator())
}.sortedWithSafe(SourceLocaleComparator())
}
private class SourceLocaleComparator : Comparator<SourceLocale?> {

View File

@@ -8,7 +8,8 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
sealed interface SourceCatalogItem : ListModel {
data class Source(
val source: MangaSource
val source: MangaSource,
val showSummary: Boolean,
) : SourceCatalogItem {
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getSummary
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
@@ -43,6 +44,12 @@ fun sourceCatalogItemSourceAD(
} else {
item.source.title
}
if (item.showSummary) {
binding.textViewDescription.text = item.source.getSummary(context)
binding.textViewDescription.isVisible = true
} else {
binding.textViewDescription.isVisible = false
}
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)

View File

@@ -1,34 +1,35 @@
package org.koitharu.kotatsu.settings.sources.catalog
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import coil.ImageLoader
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
import javax.inject.Inject
@AndroidEntryPoint
class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
TabLayout.OnTabSelectedListener,
OnListItemClickListener<SourceCatalogItem.Source>,
AppBarOwner {
AppBarOwner, MenuItem.OnActionExpandListener {
@Inject
lateinit var coil: ImageLoader
@@ -54,9 +55,9 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
ReversibleActionObserver(viewBinding.recyclerView),
)
viewModel.locale.observe(this) {
supportActionBar?.subtitle = it.getLocaleDisplayName()
supportActionBar?.subtitle = it.getLocaleDisplayName(this)
}
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel))
addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this))
}
override fun onWindowInsetsChanged(insets: Insets) {
@@ -83,6 +84,19 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
viewBinding.recyclerView.firstVisibleItemPosition = 0
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
viewBinding.tabs.isVisible = false
val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty()
viewModel.performSearch(sq)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
viewBinding.tabs.isVisible = true
viewModel.performSearch(null)
return true
}
private fun initTabs() {
val tabs = viewBinding.tabs
for (type in ContentType.entries) {
@@ -96,12 +110,4 @@ class SourcesCatalogActivity : BaseActivity<ActivitySourcesCatalogBinding>(),
}
tabs.addOnTabSelectedListener(this)
}
private fun String?.getLocaleDisplayName(): String {
if (this == null) {
return getString(R.string.various_languages)
}
val lc = Locale(this)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
}

View File

@@ -28,7 +28,7 @@ class SourcesCatalogListProducer @AssistedInject constructor(
) : InvalidationTracker.Observer(TABLE_SOURCES), RetainedLifecycle.OnClearedListener {
private val scope = lifecycle.lifecycleScope
private var query: String = ""
private var query: String? = null
val list = MutableStateFlow(emptyList<SourceCatalogItem>())
private var job = scope.launch(Dispatchers.Default) {
@@ -54,20 +54,21 @@ class SourcesCatalogListProducer @AssistedInject constructor(
}
}
fun setQuery(value: String) {
fun setQuery(value: String?) {
this.query = value
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceCatalogItem> {
val sources = repository.getDisabledSources().toMutableList()
sources.retainAll { it.contentType == contentType && it.locale == locale }
if (query.isNotEmpty()) {
sources.retainAll { it.title.contains(query, ignoreCase = true) }
when (val q = query) {
null -> sources.retainAll { it.contentType == contentType && it.locale == locale }
"" -> return emptyList()
else -> sources.retainAll { it.title.contains(q, ignoreCase = true) }
}
return if (sources.isEmpty()) {
listOf(
if (query.isEmpty()) {
if (query == null) {
SourceCatalogItem.Hint(
icon = R.drawable.ic_empty_feed,
title = R.string.no_manga_sources,
@@ -86,6 +87,7 @@ class SourcesCatalogListProducer @AssistedInject constructor(
sources.map {
SourceCatalogItem.Source(
source = it,
showSummary = query != null,
)
}
}

View File

@@ -9,12 +9,13 @@ import androidx.appcompat.widget.PopupMenu
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.util.toTitleCase
class SourcesCatalogMenuProvider(
private val activity: Activity,
private val viewModel: SourcesCatalogViewModel,
private val expandListener: MenuItem.OnActionExpandListener,
) : MenuProvider,
MenuItem.OnActionExpandListener,
SearchView.OnQueryTextListener {
@@ -40,33 +41,34 @@ class SourcesCatalogMenuProvider(
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
return true
return expandListener.onMenuItemActionExpand(item)
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
(item.actionView as SearchView).setQuery("", false)
return true
return expandListener.onMenuItemActionCollapse(item)
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performSearch(newText.orEmpty())
viewModel.performSearch(newText?.trim().orEmpty())
return true
}
private fun showLocalesMenu() {
val locales = viewModel.locales
val locales = viewModel.locales.map {
it to it.getLocaleDisplayName(activity)
}
val anchor: View = (activity as AppBarOwner).appBar.let {
it.findViewById<View?>(R.id.toolbar) ?: it
}
val menu = PopupMenu(activity, anchor)
for ((i, lc) in locales.withIndex()) {
val title = lc?.getDisplayLanguage(lc)?.toTitleCase(lc) ?: activity.getString(R.string.various_languages)
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second)
}
menu.setOnMenuItemClickListener {
viewModel.setLocale(locales.getOrNull(it.order)?.language)
viewModel.setLocale(locales.getOrNull(it.order)?.first)
true
}
menu.show()

View File

@@ -15,7 +15,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.LocaleComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
@@ -29,15 +28,15 @@ import javax.inject.Inject
class SourcesCatalogViewModel @Inject constructor(
private val repository: MangaSourcesRepository,
private val listProducerFactory: SourcesCatalogListProducer.Factory,
private val settings: AppSettings,
settings: AppSettings,
) : BaseViewModel() {
private val lifecycle = RetainedLifecycleImpl()
private var searchQuery: String = ""
private var searchQuery: String? = null
val onActionDone = MutableEventFlow<ReversibleAction>()
val contentType = MutableStateFlow(ContentType.entries.first())
val locales = getLocalesImpl()
val locale = MutableStateFlow(locales.firstOrNull()?.language)
val locales = repository.allMangaSources.mapToSet { it.locale }
val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales })
val isNsfwDisabled = settings.isNsfwContentDisabled
@@ -59,7 +58,7 @@ class SourcesCatalogViewModel @Inject constructor(
lifecycle.dispatchOnCleared()
}
fun performSearch(query: String) {
fun performSearch(query: String?) {
searchQuery = query
listProducer.value?.setQuery(query)
}
@@ -78,10 +77,4 @@ class SourcesCatalogViewModel @Inject constructor(
onActionDone.call(ReversibleAction(R.string.source_enabled, rollback))
}
}
private fun getLocalesImpl(): List<Locale?> {
return repository.allMangaSources
.mapToSet { it.locale?.let(::Locale) }
.sortedWith(LocaleComparator())
}
}

View File

@@ -62,6 +62,7 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.model.SortOrder
@@ -208,7 +209,15 @@ class SuggestionsWorker @AssistedInject constructor(
val tag = tags.firstNotNullOfOrNull { title ->
availableTags.find { x -> x.title.almostEquals(title, TAG_EQ_THRESHOLD) }
}
val list = repository.getList(0, setOfNotNull(tag), order).asArrayList()
val list = repository.getList(
offset = 0,
filter = MangaListFilter.Advanced(
sortOrder = order,
tags = setOfNotNull(tag),
locale = null,
states = setOf(),
),
).asArrayList()
if (appSettings.isSuggestionsExcludeNsfw) {
list.removeAll { it.isNsfw }
}

View File

@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import coil.request.CachePolicy
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
@@ -76,7 +78,9 @@ class Tracker @Inject constructor(
}
suspend fun fetchUpdates(track: MangaTracking, commit: Boolean): MangaUpdates.Success {
val manga = mangaRepositoryFactory.create(track.manga.source).getDetails(track.manga)
val repo = mangaRepositoryFactory.create(track.manga.source)
require(repo is RemoteMangaRepository) { "Repository ${repo.javaClass.simpleName} is not supported" }
val manga = repo.getDetails(track.manga, CachePolicy.WRITE_ONLY)
val updates = compare(track, manga, getBranch(manga))
if (commit) {
repository.saveUpdates(updates)
@@ -120,7 +124,7 @@ class Tracker @Inject constructor(
manga = manga,
newChapters = emptyList(),
isValid = chapters.lastOrNull()?.id == track.lastChapterId,
channelId = null
channelId = null,
)
}

View File

@@ -23,17 +23,34 @@
app:shapeAppearance="?shapeAppearanceCornerSmall"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/textView_title"
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="?android:listPreferredItemPaddingStart"
android:layout_marginEnd="?android:listPreferredItemPaddingEnd"
android:layout_weight="1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[15]" />
android:orientation="vertical">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceTitleSmall"
tools:text="@tools:sample/lorem[15]" />
<TextView
android:id="@+id/textView_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodySmall"
tools:text="English" />
</LinearLayout>
<ImageView
android:id="@+id/imageView_add"

View File

@@ -506,4 +506,26 @@
<string name="backups_output_directory">Вывадны каталог рэзервовых копій</string>
<string name="speed_value">x%.1f</string>
<string name="lock_screen_rotation">Блакаванне павароту экрана</string>
<string name="sources_catalog">Каталог крыніц</string>
<string name="content_type_manga">Манга</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="content_type_hentai">Хентай</string>
<string name="content_type_comics">Коміксы</string>
<string name="catalog">Каталог</string>
<string name="manage_sources">Кіраванне крыніцамі</string>
<string name="no_manga_sources_found">Па вашаму запыту не знойдзена даступных крыніц мангі</string>
<string name="manual">Уручную</string>
<string name="source_enabled">Крыніца ўключана</string>
<string name="disable_nsfw_summary">Адключыць крыніцы NSFW і схавайць мангу для дарослых са спісу, калі гэта магчыма</string>
<string name="no_manga_sources_catalog_text">У гэтым раздзеле няма даступных крыніц, ці ўсе яны маглі быць ужо дададзены.
\nСачыце за абнаўленнямі</string>
<string name="available_d">Даступна: %1$d</string>
<string name="content_type_other">Іншае</string>
<string name="state_paused">Прыпынена</string>
<string name="error_multiple_states_not_supported">Фільтраванне па некалькіх станам не падтрымліваецца гэтай крыніцай мангі</string>
<string name="reader_optimize">Памяншэнне спажывання памяці (бэта)</string>
<string name="error_multiple_genres_not_supported">Фільтраванне па некалькіх жанрах не падтрымліваецца гэтай крыніцай мангі</string>
<string name="error_search_not_supported">Пошук не падтрымліваецца гэтай крыніцай мангі</string>
<string name="reader_optimize_summary">Паменшыць якасць закадравых старонак, каб выкарыстоўваць менш памяці</string>
<string name="state">Стан</string>
</resources>

View File

@@ -9,8 +9,8 @@
<item quantity="other">%1$d টি নতুন পর্ব</item>
</plurals>
<plurals name="days_ago">
<item quantity="one">মাত্র %1$d আগে</item>
<item quantity="other">মাত্র %1$d দিন আগে</item>
<item quantity="one">মাত্র %1$d দিন আগে</item>
<item quantity="other">%1$d দিন আগে</item>
</plurals>
<plurals name="months_ago">
<item quantity="one">প্রায় %1$d মাস আগে</item>
@@ -21,11 +21,11 @@
<item quantity="other">%1$d টি পর্ব</item>
</plurals>
<plurals name="hours_ago">
<item quantity="one">%1$d মাত্র ঘন্টা আগে</item>
<item quantity="one">মাত্র %1$d ঘন্টা আগে</item>
<item quantity="other">%1$d ঘন্টা আগে</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="one">মাত্র %1$d মিনিট আগে</item>
<item quantity="other">%1$d মিনিট আগে</item>
<item quantity="other">%1$d মিনিট আগে</item>
</plurals>
</resources>

View File

@@ -513,11 +513,19 @@
<string name="content_type_comics">Cómic</string>
<string name="no_manga_sources_found">No se han encontrado fuentes de manga disponibles en su búsqueda</string>
<string name="source_enabled">Fuente habilitada</string>
<string name="no_manga_sources_catalog_text">Aún no hay fuentes disponibles en esta sección. Permanezca atento</string>
<string name="no_manga_sources_catalog_text">No hay fuentes disponibles en esta sección, o puede que ya se hayan añadido todas.
\nPermanezca atento</string>
<string name="content_type_other">Otros</string>
<string name="catalog">Catálogo</string>
<string name="manage_sources">Gestionar las fuentes</string>
<string name="manual">Manual</string>
<string name="disable_nsfw_summary">Desactivar las fuentes NSFW y ocultar el manga para adultos de la lista si es posible</string>
<string name="available_d">Disponible: %1$d</string>
<string name="state_paused">Pausado</string>
<string name="error_multiple_states_not_supported">El filtrado por múltiples estados no es compatible con esta fuente del manga</string>
<string name="reader_optimize">Reducir el consumo de memoria (beta)</string>
<string name="error_multiple_genres_not_supported">Esta fuente del manga no permite filtrar por varios géneros</string>
<string name="error_search_not_supported">La búsqueda no es compatible con esta fuente del manga</string>
<string name="reader_optimize_summary">Reduce la resolución de las páginas fuera de pantalla para usar menos memoria</string>
<string name="state">Estado</string>
</resources>

View File

@@ -483,4 +483,49 @@
<string name="zoom_out">Réduire</string>
<string name="manga_list">Liste des mangas</string>
<string name="to_top">En haut</string>
<string name="sources_catalog">Sources catalogue</string>
<string name="frequency_every_day">Tous les jours</string>
<string name="categories">Catégories</string>
<string name="list_options">Options de liste</string>
<string name="content_type_manga">Mangas</string>
<string name="error_multiple_states_not_supported">Filtrer par plusieurs états n\'est pas pris en charge par cette source de mangas</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="backup_frequency">Fréquence de création de sauvegarde</string>
<string name="content_type_hentai">Hentais</string>
<string name="suggest_new_sources">Suggérer de nouvelles sources après la mise à jour de l\'application</string>
<string name="periodic_backups_enable">Activer les sauvegardes périodiques</string>
<string name="content_type_comics">Comics</string>
<string name="catalog">Catalogue</string>
<string name="enhanced_colors_summary">Réduit le crénelage, mais peut avoir une incidence sur les performances</string>
<string name="frequency_every_2_days">Tous les 2 jours</string>
<string name="reader_optimize">Réduire la consommation de mémoire (bêta)</string>
<string name="manage_sources">Gérer les sources</string>
<string name="no_manga_sources_found">Aucune source de manga disponible trouvée par votre requête</string>
<string name="frequency_once_per_week">Une fois par semaine</string>
<string name="periodic_backups">Sauvegardes périodiques</string>
<string name="frequency_twice_per_month">Deux fois par mois</string>
<string name="online_variant">Variante en ligne</string>
<string name="error_multiple_genres_not_supported">Filtrer par plusieurs genres n\'est pas pris en charge par cette source manga</string>
<string name="lock_screen_rotation">Verrouiller la rotation de l\'écran</string>
<string name="by_relevance">Pertinence</string>
<string name="state_abandoned">Abandonné</string>
<string name="keep_screen_on">Garder l\'écran allumé</string>
<string name="error_search_not_supported">La recherche n\'est pas prise en charge par cette source de mangas</string>
<string name="frequency_once_per_month">Une fois par mois</string>
<string name="manual">Manuel</string>
<string name="reader_optimize_summary">Réduire la qualité des pages hors écran pour utiliser moins de mémoire</string>
<string name="source_enabled">Source activée</string>
<string name="enhanced_colors">mode couleur 32-bit</string>
<string name="disable_nsfw_summary">Désactiver les sources pornographiques et masquer les mangas pour adultes de la liste si possible</string>
<string name="speed_value">x%.1f</string>
<string name="keep_screen_on_summary">Ne pas désactiver l\'écran pendant que vous lisez des mangas</string>
<string name="no_manga_sources_catalog_text">Il n\'y a pas de sources disponibles dans cette section, ou tout cela aurait pu être ajouté.
\nRestez à l\'écoute</string>
<string name="available_d">Disponible : %1$d</string>
<string name="state">État</string>
<string name="last_successful_backup">Dernière sauvegarde réussie : %s</string>
<string name="state_paused">En pause</string>
<string name="backups_output_directory">Sauvegardes répertoire de sortie</string>
<string name="suggest_new_sources_summary">Prompt pour permettre des sources nouvellement ajoutées après la mise à jour de l\'application</string>
<string name="content_type_other">Autre</string>
</resources>

View File

@@ -516,7 +516,16 @@
<string name="manual">Вручную</string>
<string name="source_enabled">Источник включен</string>
<string name="disable_nsfw_summary">Отключить NSFW источники и скрывать мангу для взрослых в списках, если это возможно</string>
<string name="no_manga_sources_catalog_text">Пока что в данном разделе нет доступных источников. Следите за обновлениями</string>
<string name="no_manga_sources_catalog_text">В этом разделе нет доступных источников, или все они могли быть уже добавлены.
\nСледите за обновлениями</string>
<string name="available_d">Доступно: %1$d</string>
<string name="content_type_other">Другое</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="state_paused">Приостановлено</string>
<string name="error_multiple_states_not_supported">Фильтрация по нескольким состояниям не поддерживается этим источником манги</string>
<string name="reader_optimize">Уменьшение потребления памяти (бета)</string>
<string name="error_multiple_genres_not_supported">Фильтрация по нескольким жанрам не поддерживается этим источником манги</string>
<string name="error_search_not_supported">Поиск не поддерживается этим источником манги</string>
<string name="reader_optimize_summary">Уменьшить качество закадровых страниц, чтобы использовать меньше памяти</string>
<string name="state">Состояние</string>
</resources>

View File

@@ -30,7 +30,7 @@
<string name="download_complete">İndirildi</string>
<string name="downloads">İndirilenler</string>
<string name="by_name">Ad</string>
<string name="updated">Güncellenme</string>
<string name="updated">Güncellendi</string>
<string name="newest">Yeniler</string>
<string name="by_rating">Puanlama</string>
<string name="filter">Litre</string>
@@ -112,7 +112,7 @@
<string name="restore_backup">Yedekten geri yükle</string>
<string name="update">Güncelle</string>
<string name="sign_in">Oturum aç</string>
<string name="state_finished">Bitti</string>
<string name="state_finished">Tamamlandı</string>
<string name="about">Hakkında</string>
<string name="auth_required">Bu içeriği görüntülemek için oturum açın</string>
<string name="confirm">Onayla</string>
@@ -505,4 +505,27 @@
<string name="backups_output_directory">Yedekleme dizini</string>
<string name="speed_value">x%.1f</string>
<string name="last_successful_backup">Son başarılı yedekleme: %s</string>
<string name="sources_catalog">Kaynak kataloğu</string>
<string name="content_type_manga">Manga</string>
<string name="content_type_hentai">Hentai</string>
<string name="catalog">Katalog</string>
<string name="reader_optimize">Bellek kullanımını düşür (beta)</string>
<string name="manage_sources">Kaynakları yönet</string>
<string name="reader_optimize_summary">Daha az bellek kullanımı için ekran dışı sayfaların çözünürlüğünü düşür</string>
<string name="source_enabled">Kaynak etkinleştirildi</string>
<string name="no_manga_sources_catalog_text">Bu bölüm için kullanılabilir kaynak yok ya da hepsi zaten eklenmiş olabilir.
\nTakipte kalın</string>
<string name="state_paused">Durduruldu</string>
<string name="content_type_other">Diğer</string>
<string name="error_multiple_states_not_supported">Birden fazla duruma göre filtreleme bu manga kaynağı tarafından desteklenmemektedir</string>
<string name="source_summary_pattern">%1$s,%2$s</string>
<string name="content_type_comics">Karikatür</string>
<string name="no_manga_sources_found">Sorgunuza göre mevcut manga kaynağı bulunamadı</string>
<string name="error_multiple_genres_not_supported">Birden fazla türe göre filtreleme bu manga kaynağı tarafından desteklenmemektedir</string>
<string name="lock_screen_rotation">Ekran döndürmeyi kilitle</string>
<string name="error_search_not_supported">Arama bu manga kaynağı tarafından desteklenmemektedir</string>
<string name="manual">Manuel</string>
<string name="disable_nsfw_summary">Eğer mümkünse yetişkin içerik ve NSFW kaynaklarını arama listesinden kaldır</string>
<string name="available_d">Mevcut:%1$d</string>
<string name="state">Durum</string>
</resources>

View File

@@ -506,4 +506,26 @@
<string name="backups_output_directory">Вихідний каталог резервних копій</string>
<string name="speed_value">x%.1f</string>
<string name="lock_screen_rotation">Блокування повороту екрану</string>
<string name="sources_catalog">Каталог джерел</string>
<string name="content_type_manga">Манґа</string>
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="content_type_hentai">Хентай</string>
<string name="content_type_comics">Комікси</string>
<string name="catalog">Каталог</string>
<string name="manage_sources">Кіравання джерелами</string>
<string name="no_manga_sources_found">За вашим запитом не знайдено доступних джерел манґи</string>
<string name="manual">Вручну</string>
<string name="source_enabled">Джерело включено</string>
<string name="disable_nsfw_summary">Вимкніть джерела NSFW і приховайте манґу для дорослих зі списку, якщо можливо</string>
<string name="no_manga_sources_catalog_text">У цьому розділі немає доступних джерел, або всі вони могли бути додані.
\nСлідкуйте за оновленнями</string>
<string name="available_d">Доступно: %1$d</string>
<string name="content_type_other">Інший</string>
<string name="state_paused">Призупинено</string>
<string name="error_multiple_states_not_supported">Фільтрування за кількома станами не підтримується цим джерелом манґи</string>
<string name="reader_optimize">Зменшення споживання пам\'яті (бета)</string>
<string name="error_multiple_genres_not_supported">Фільтрація за кількома жанрами не підтримується цим джерелом манґи</string>
<string name="error_search_not_supported">Пошук не підтримується цим джерелом манґи</string>
<string name="reader_optimize_summary">Зменшити якість закадрових сторінок, щоб використовувати менше пам\'яті</string>
<string name="state">Стан</string>
</resources>

View File

@@ -519,11 +519,18 @@
<string name="source_summary_pattern">%1$s, %2$s</string>
<string name="sources_catalog">Sources catalog</string>
<string name="source_enabled">Source enabled</string>
<string name="no_manga_sources_catalog_text">No available sources in this section yet. Stay tuned</string>
<string name="no_manga_sources_catalog_text">There are no sources available in this section, or all of it might have been already added.\nStay tuned</string>
<string name="no_manga_sources_found">No available manga sources found by your query</string>
<string name="catalog">Catalog</string>
<string name="manage_sources">Manage sources</string>
<string name="manual">Manual</string>
<string name="available_d">Available: %1$d</string>
<string name="disable_nsfw_summary">Disable NSFW sources and hide adult manga from list if possible</string>
<string name="state_paused">Paused</string>
<string name="reader_optimize">Reduce memory consumption (beta)</string>
<string name="reader_optimize_summary">Reduce offscreen pages quality to use less memory</string>
<string name="state">State</string>
<string name="error_multiple_genres_not_supported">Filtering by multiple genres is not supported by this manga source</string>
<string name="error_multiple_states_not_supported">Filtering by multiple states is not supported by this manga source</string>
<string name="error_search_not_supported">Search is not supported by this manga source</string>
</resources>

View File

@@ -60,6 +60,12 @@
android:summary="@string/enhanced_colors_summary"
android:title="@string/enhanced_colors" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="reader_optimize"
android:summary="@string/reader_optimize_summary"
android:title="@string/reader_optimize" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="reader_bar"

View File

@@ -6,6 +6,7 @@ import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.NotFoundException
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.MangaTag
@@ -17,21 +18,16 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSourc
override val configKeyDomain: ConfigKey.Domain
get() = ConfigKey.Domain("localhost")
override val sortOrders: Set<SortOrder>
override val availableSortOrders: Set<SortOrder>
get() = EnumSet.allOf(SortOrder::class.java)
override suspend fun getDetails(manga: Manga): Manga = stub()
override suspend fun getList(
offset: Int,
query: String?,
tags: Set<MangaTag>?,
sortOrder: SortOrder,
): List<Manga> = stub()
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub()
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub()
override suspend fun getTags(): Set<MangaTag> = stub()
override suspend fun getAvailableTags(): Set<MangaTag> = stub()
private fun stub(): Nothing {
throw NotFoundException("Usage of Dummy parser in release build", "")