Compare commits

...

23 Commits

Author SHA1 Message Date
4eb4ec7de0 Fix nsfw sources filtering 2023-09-12 19:33:49 +03:00
ecb4dd87d9 Update parsers 2023-09-12 19:28:45 +03:00
Nayuki
3d0f5f75cd Translated using Weblate (Thai)
Currently translated at 63.3% (306 of 483 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Bander AL-shreef
c5462e8454 Translated using Weblate (Arabic)
Currently translated at 37.4% (181 of 483 strings)

Co-authored-by: Bander AL-shreef <bander.alshreef@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
return_null
5039e324fb Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 93.1% (450 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 92.5% (447 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-12 18:31:26 +03:00
Макар Разин
b251b3e654 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 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-09-12 18:31:26 +03:00
gallegonovato
5f10070564 Translated using Weblate (Spanish)
Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Clxff H3r4ld0
3da6f80eb6 Translated using Weblate (Indonesian)
Currently translated at 100.0% (481 of 481 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
InfinityDouki56
4b2cfdb972 Translated using Weblate (Filipino)
Currently translated at 89.7% (428 of 477 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
kuragehime
51387ace7e Translated using Weblate (Japanese)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (481 of 481 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (477 of 477 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
2bdb83ff28 Fix navigation reordering 2023-09-12 13:38:03 +03:00
a1b85433ec Fix bookmarks crash #492 2023-09-12 13:38:03 +03:00
Isira Seneviratne
ca5207c658 Use ancestors and descendants extensions 2023-09-09 17:52:12 +03:00
81de6124f0 Rethrow CancellationException from TrackWorkers #489 2023-09-09 17:42:47 +03:00
a93bc0ed5b Increase max autoscroll speed 2023-09-08 13:48:34 +03:00
a1b96ebbb5 Update parsers 2023-09-08 13:30:26 +03:00
6b93e49f56 Improve loading both local and remote manga 2023-09-08 13:07:26 +03:00
c88a9dff36 Handle enter press in search view 2023-09-08 09:17:04 +03:00
ca47c475d3 Avoid passing manga chapters via extras 2023-09-07 18:15:48 +03:00
8df7fa2729 Fix crashes 2023-09-07 16:40:07 +03:00
ea34abb1d7 Fix categories reordering 2023-09-07 14:06:52 +03:00
c4ff37350c Option to move manga source to top 2023-09-07 13:27:13 +03:00
95547a8d03 Configurable main navigation 2023-09-06 14:42:00 +03:00
101 changed files with 1881 additions and 905 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 576
versionName = '6.0.3'
versionCode = 578
versionName = '6.1.1'
generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp {
@@ -81,7 +81,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:2f7e704e21') {
implementation('com.github.KotatsuApp:kotatsu-parsers:7fbeb2e266') {
exclude group: 'org.json', module: 'json'
}

View File

@@ -14,7 +14,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -25,8 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
@@ -38,7 +36,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject
@@ -61,11 +58,17 @@ class BookmarksFragment :
private var bookmarksAdapter: BookmarksAdapter? = null
private var selectionController: ListSelectionController? = null
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentListSimpleBinding {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentListSimpleBinding {
return FragmentListSimpleBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: FragmentListSimpleBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: FragmentListSimpleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
selectionController = ListSelectionController(
activity = requireActivity(),
@@ -95,8 +98,11 @@ class BookmarksFragment :
viewModel.content.observe(viewLifecycleOwner) {
bookmarksAdapter?.setItems(it, spanSizeLookup)
}
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone)
viewModel.onError.observeEvent(
viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this)
)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
}
override fun onDestroyView() {
@@ -139,12 +145,20 @@ class BookmarksFragment :
requireViewBinding().recyclerView.invalidateItemDecorations()
}
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_bookmarks, menu)
return true
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
val ids = selectionController?.snapshot() ?: return false
@@ -167,16 +181,6 @@ class BookmarksFragment :
}
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init {
@@ -185,7 +189,8 @@ class BookmarksFragment :
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount
?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
@@ -200,6 +205,12 @@ class BookmarksFragment :
companion object {
@Deprecated(
"", ReplaceWith(
"BookmarksFragment()",
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
)
)
fun newInstance() = BookmarksFragment()
}
}

View File

@@ -162,7 +162,7 @@ class BookmarksSheet :
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}

View File

@@ -12,7 +12,12 @@ import androidx.core.net.toUri
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.yield
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
@@ -118,11 +123,14 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
}
R.id.action_retry -> {
viewBinding.webView.stopLoading()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
lifecycleScope.launch {
viewBinding.webView.stopLoading()
yield()
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
if (targetUrl != null) {
clearCfCookies(targetUrl)
viewBinding.webView.loadUrl(targetUrl.toString())
}
}
true
}
@@ -168,7 +176,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
}
private fun clearCfCookies(url: HttpUrl) {
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
cookieJar.removeCookies(url) { cookie ->
val name = cookie.name
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")

View File

@@ -30,7 +30,8 @@ import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.cache.StubContentCache
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
@@ -40,7 +41,6 @@ import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.activityManager
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.local.data.CacheDir
@@ -161,7 +161,7 @@ interface AppModule {
fun provideContentCache(
application: Application,
): ContentCache {
return if (application.activityManager?.isLowRamDevice == true) {
return if (application.isLowRamDevice()) {
StubContentCache()
} else {
MemoryContentCache(application)

View File

@@ -6,6 +6,7 @@ import androidx.room.InvalidationTracker
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -118,7 +119,7 @@ fun MangaDatabase(context: Context): MangaDatabase = Room
fun InvalidationTracker.removeObserverAsync(observer: InvalidationTracker.Observer) {
val scope = processLifecycleScope
if (scope.isActive) {
processLifecycleScope.launch(Dispatchers.Default) {
processLifecycleScope.launch(Dispatchers.Default, CoroutineStart.ATOMIC) {
removeObserver(observer)
}
}

View File

@@ -6,3 +6,4 @@ const val TABLE_TAGS = "tags"
const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories"
const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources"

View File

@@ -3,9 +3,10 @@ package org.koitharu.kotatsu.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
@Entity(
tableName = "sources",
tableName = TABLE_SOURCES,
)
data class MangaSourceEntity(
@PrimaryKey(autoGenerate = false)

View File

@@ -17,6 +17,8 @@ fun Collection<Manga>.distinctById() = distinctBy { it.id }
@JvmName("chaptersIds")
fun Collection<MangaChapter>.ids() = mapToSet { it.id }
fun Collection<MangaChapter>.findById(id: Long) = find { x -> x.id == id }
fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
if (size <= 1) {
return size
@@ -30,7 +32,7 @@ fun Collection<ChapterListItem>.countChaptersByBranch(): Int {
}
fun Manga.findChapter(id: Long): MangaChapter? {
return chapters?.find { it.id == id }
return chapters?.findById(id)
}
fun Manga.getPreferredBranch(history: MangaHistory?): String? {
@@ -39,7 +41,7 @@ fun Manga.getPreferredBranch(history: MangaHistory?): String? {
return null
}
if (history != null) {
val currentChapter = ch.find { it.id == history.chapterId }
val currentChapter = ch.findById(history.chapterId)
if (currentChapter != null) {
return currentChapter.branch
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.model
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
@@ -15,3 +16,5 @@ fun MangaSource(name: String): MangaSource {
}
return MangaSource.DUMMY
}
fun MangaSource.isNsfw() = contentType == ContentType.HENTAI

View File

@@ -0,0 +1,42 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@Parcelize
data class ParcelableChapter(
val chapter: MangaChapter,
) : Parcelable {
companion object : Parceler<ParcelableChapter> {
override fun create(parcel: Parcel) = ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = parcel.readSerializableCompat() ?: MangaSource.DUMMY,
)
)
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
}

View File

@@ -9,55 +9,28 @@ import org.koitharu.kotatsu.core.util.ext.readParcelableCompat
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.Manga
// Limits to avoid TransactionTooLargeException
private const val MAX_SAFE_SIZE = 1024 * 100 // Assume that 100 kb is safe parcel size
private const val MAX_SAFE_CHAPTERS_COUNT = 24 // this is 100% safe
@Parcelize
data class ParcelableManga(
val manga: Manga,
private val withChapters: Boolean,
) : Parcelable {
companion object : Parceler<ParcelableManga> {
private fun Manga.writeToParcel(out: Parcel, flags: Int, withChapters: Boolean) {
out.writeLong(id)
out.writeString(title)
out.writeString(altTitle)
out.writeString(url)
out.writeString(publicUrl)
out.writeFloat(rating)
ParcelCompat.writeBoolean(out, isNsfw)
out.writeString(coverUrl)
out.writeString(largeCoverUrl)
out.writeString(description)
out.writeParcelable(ParcelableMangaTags(tags), flags)
out.writeSerializable(state)
out.writeString(author)
val parcelableChapters = if (withChapters) null else chapters?.let(::ParcelableMangaChapters)
out.writeParcelable(parcelableChapters, flags)
out.writeSerializable(source)
}
override fun ParcelableManga.write(parcel: Parcel, flags: Int) {
val chapters = manga.chapters
if (!withChapters || chapters == null) {
manga.writeToParcel(parcel, flags, withChapters = false)
return
}
if (chapters.size <= MAX_SAFE_CHAPTERS_COUNT) {
// fast path
manga.writeToParcel(parcel, flags, withChapters = true)
return
}
val tempParcel = Parcel.obtain()
manga.writeToParcel(tempParcel, flags, withChapters = true)
val size = tempParcel.dataSize()
if (size < MAX_SAFE_SIZE) {
parcel.appendFrom(tempParcel, 0, size)
} else {
manga.writeToParcel(parcel, flags, withChapters = false)
}
tempParcel.recycle()
companion object : Parceler<ParcelableManga> {
override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) {
parcel.writeLong(id)
parcel.writeString(title)
parcel.writeString(altTitle)
parcel.writeString(url)
parcel.writeString(publicUrl)
parcel.writeFloat(rating)
ParcelCompat.writeBoolean(parcel, isNsfw)
parcel.writeString(coverUrl)
parcel.writeString(largeCoverUrl)
parcel.writeString(description)
parcel.writeParcelable(ParcelableMangaTags(tags), flags)
parcel.writeSerializable(state)
parcel.writeString(author)
parcel.writeSerializable(source)
}
override fun create(parcel: Parcel) = ParcelableManga(
@@ -75,10 +48,9 @@ data class ParcelableManga(
tags = requireNotNull(parcel.readParcelableCompat<ParcelableMangaTags>()).tags,
state = parcel.readSerializableCompat(),
author = parcel.readString(),
chapters = parcel.readParcelableCompat<ParcelableMangaChapters>()?.chapters,
chapters = null,
source = requireNotNull(parcel.readSerializableCompat()),
),
withChapters = true
)
)
}
}

View File

@@ -1,37 +0,0 @@
package org.koitharu.kotatsu.core.model.parcelable
import android.os.Parcel
import android.os.Parcelable
import kotlinx.parcelize.Parceler
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.TypeParceler
import org.koitharu.kotatsu.core.util.ext.readSerializableCompat
import org.koitharu.kotatsu.parsers.model.MangaChapter
object MangaChapterParceler : Parceler<MangaChapter> {
override fun create(parcel: Parcel) = MangaChapter(
id = parcel.readLong(),
name = requireNotNull(parcel.readString()),
number = parcel.readInt(),
url = requireNotNull(parcel.readString()),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
branch = parcel.readString(),
source = requireNotNull(parcel.readSerializableCompat()),
)
override fun MangaChapter.write(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)
parcel.writeString(branch)
parcel.writeSerializable(source)
}
}
@Parcelize
@TypeParceler<MangaChapter, MangaChapterParceler>
data class ParcelableMangaChapters(val chapters: List<MangaChapter>) : Parcelable

View File

@@ -47,7 +47,6 @@ class AndroidCookieJar : MutableCookieJar {
.build()
cookieManager.setCookie(urlString, nc.toString())
}
check(loadForRequest(url).isEmpty())
}
override suspend fun clear() = suspendCoroutine<Boolean> { continuation ->

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.history.domain.model.HistoryOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.io.File
@@ -43,7 +44,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull()
?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
val colorScheme: ColorScheme
get() = prefs.getEnumValue(KEY_COLOR_THEME, ColorScheme.default)
@@ -51,8 +53,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isAmoledTheme: Boolean
get() = prefs.getBoolean(KEY_THEME_AMOLED, false)
val isFavoritesNavItemFirst: Boolean
get() = (prefs.getString(KEY_FIRST_NAV_ITEM, null)?.toIntOrNull() ?: 0) == 1
var mainNavItems: List<NavItem>
get() {
val raw = prefs.getString(KEY_NAV_MAIN, null)?.split(',')
return if (raw.isNullOrEmpty()) {
listOf(NavItem.HISTORY, NavItem.FAVORITES, NavItem.EXPLORE, NavItem.FEED)
} else {
raw.mapNotNull { x -> NavItem.entries.find(x) }.ifEmpty { listOf(NavItem.EXPLORE) }
}
}
set(value) {
prefs.edit {
putString(KEY_NAV_MAIN, value.joinToString(",") { it.name })
}
}
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
@@ -145,7 +159,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD) }
set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD
)
}
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -171,7 +189,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
if (isBackgroundNetworkRestricted()) {
return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
val policy =
NetworkPolicy.from(prefs.getString(KEY_PREFETCH_CONTENT, null), NetworkPolicy.NEVER)
return policy.isNetworkAllowed(connectivityManager)
}
@@ -292,14 +311,22 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
@get:FloatRange(from = 0.0, to = 1.0)
var readerAutoscrollSpeed: Float
get() = prefs.getFloat(KEY_READER_AUTOSCROLL_SPEED, 0f)
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { putFloat(KEY_READER_AUTOSCROLL_SPEED, value) }
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit {
putFloat(
KEY_READER_AUTOSCROLL_SPEED,
value
)
}
val isPagesPreloadEnabled: Boolean
get() {
if (isBackgroundNetworkRestricted()) {
return false
}
val policy = NetworkPolicy.from(prefs.getString(KEY_PAGES_PRELOAD, null), NetworkPolicy.NON_METERED)
val policy = NetworkPolicy.from(
prefs.getString(KEY_PAGES_PRELOAD, null),
NetworkPolicy.NON_METERED
)
return policy.isNetworkAllowed(connectivityManager)
}
@@ -455,7 +482,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_MANGA_DIRS = "local_manga_dirs"
const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_FIRST_NAV_ITEM = "nav_first"
const val KEY_NAV_MAIN = "nav_main"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.DrawableRes
import androidx.annotation.IdRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.list.ui.model.ListModel
enum class NavItem(
@IdRes val id: Int,
@StringRes val title: Int,
@DrawableRes val icon: Int,
) : ListModel {
HISTORY(R.id.nav_history, R.string.history, R.drawable.ic_history_selector),
FAVORITES(R.id.nav_favorites, R.string.favourites, R.drawable.ic_favourites_selector),
LOCAL(R.id.nav_local, R.string.on_device, R.drawable.ic_storage_selector),
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
;
override fun areItemsTheSame(other: ListModel): Boolean {
return other is NavItem && ordinal == other.ordinal
}
fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled
else -> true
}
}

View File

@@ -19,13 +19,13 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.GravityCompat
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt
import com.google.android.material.R as materialR
@@ -522,7 +522,7 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
}
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p ->
private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup
} else {

View File

@@ -9,10 +9,10 @@ import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes
import androidx.core.view.ancestors
import androidx.core.view.isGone
import androidx.core.view.isVisible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@@ -81,14 +81,9 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
}
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) {
val layoutParams = (p as? View)?.layoutParams
if (layoutParams is CoordinatorLayout.LayoutParams) {
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
return ancestors.firstNotNullOfOrNull {
((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)
?.let { params -> AdaptiveSheetBehavior.from(params) }
}
return null
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.viewpager.widget.ViewPager
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@SuppressLint("ClickableViewAccessibility")
class EnhancedViewPager @JvmOverloads constructor(
@@ -25,6 +26,11 @@ class EnhancedViewPager @JvmOverloads constructor(
}
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return isUserInputEnabled && super.onInterceptTouchEvent(event)
return try {
isUserInputEnabled && super.onInterceptTouchEvent(event)
} catch (e: IllegalArgumentException) {
e.printStackTraceDebug()
false
}
}
}

View File

@@ -5,6 +5,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
@@ -72,7 +74,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
transform: suspend (T1, T2, T3, T4, T5, T6) -> R
transform: suspend (T1, T2, T3, T4, T5, T6) -> R,
): Flow<R> = combine(flow, flow2, flow3, flow4, flow5, flow6) { args: Array<*> ->
transform(
args[0] as T1,
@@ -83,3 +85,7 @@ fun <T1, T2, T3, T4, T5, T6, R> combine(
args[5] as T6,
)
}
suspend fun <T : Any> Flow<T?>.firstNotNull(): T = checkNotNull(first { x -> x != null })
suspend fun <T : Any> Flow<T?>.firstNotNullOrNull(): T? = firstOrNull { x -> x != null }

View File

@@ -5,13 +5,14 @@ import android.graphics.Rect
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager
import android.widget.Checkable
import android.widget.CompoundButton
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt
@@ -88,23 +89,8 @@ fun Slider.setValueRounded(newValue: Float) {
value = roundedValue.coerceIn(valueFrom, valueTo)
}
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}
fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach {
descendants.filterIsInstance<RecyclerView>().forEach {
it.invalidateItemDecorations()
}
}
@@ -112,15 +98,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup?
get() = parent as? ViewGroup
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int
val specMode = MeasureSpec.getMode(measureSpec)
@@ -155,3 +132,11 @@ fun TabLayout.setTabsEnabled(enabled: Boolean) {
getTabAt(i)?.view?.isEnabled = enabled
}
}
fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (value) {
if (!isVisible) show()
} else {
if (isVisible) hide()
}
}

View File

@@ -1,8 +1,11 @@
package org.koitharu.kotatsu.details.domain
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
@@ -23,24 +26,28 @@ class DoubleMangaLoadUseCase @Inject constructor(
private val recoverUseCase: RecoverMangaUseCase,
) {
suspend operator fun invoke(manga: Manga): DoubleManga = coroutineScope {
val remoteDeferred = async(Dispatchers.Default) { loadRemote(manga) }
val localDeferred = async(Dispatchers.Default) { loadLocal(manga) }
DoubleManga(
remoteManga = remoteDeferred.await(),
localManga = localDeferred.await(),
)
}
operator fun invoke(manga: Manga): Flow<DoubleManga> = flow<DoubleManga> {
var lastValue: DoubleManga? = null
var emitted = false
invokeImpl(manga).collect {
lastValue = it
if (it.any != null) {
emitted = true
emit(it)
}
}
if (!emitted) {
lastValue?.requireAny()
}
}.flowOn(Dispatchers.Default)
suspend operator fun invoke(mangaId: Long): DoubleManga {
val manga = mangaDataRepository.findMangaById(mangaId) ?: throwNFE()
return invoke(manga)
}
operator fun invoke(mangaId: Long): Flow<DoubleManga> = flow {
emit(mangaDataRepository.findMangaById(mangaId) ?: throwNFE())
}.flatMapLatest { invoke(it) }
suspend operator fun invoke(intent: MangaIntent): DoubleManga {
val manga = mangaDataRepository.resolveIntent(intent) ?: throwNFE()
return invoke(manga)
}
operator fun invoke(intent: MangaIntent): Flow<DoubleManga> = flow {
emit(mangaDataRepository.resolveIntent(intent) ?: throwNFE())
}.flatMapLatest { invoke(it) }
private suspend fun loadLocal(manga: Manga): Result<Manga>? {
return runCatchingCancellable {
@@ -70,5 +77,15 @@ class DoubleMangaLoadUseCase @Inject constructor(
}
}
private fun invokeImpl(manga: Manga): Flow<DoubleManga> = combine(
flow { emit(null); emit(loadRemote(manga)) },
flow { emit(null); emit(loadLocal(manga)) },
) { remote, local ->
DoubleManga(
remoteManga = remote,
localManga = local,
)
}
private fun throwNFE(): Nothing = throw NotFoundException("Cannot find manga", "")
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.domain.model
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -34,6 +35,10 @@ data class DoubleManga(
mergeChapters()
}
fun hasChapter(id: Long): Boolean {
return local?.chapters?.findById(id) != null || remote?.chapters?.findById(id) != null
}
fun requireAny(): Manga {
val result = remoteManga?.getOrNull() ?: localManga?.getOrNull()
if (result != null) {

View File

@@ -5,8 +5,9 @@ import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableChapter
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
@@ -34,12 +35,13 @@ class MangaPrefetchService : CoroutineIntentService() {
override suspend fun processIntent(startId: Int, intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return,
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga
?: return,
)
ACTION_PREFETCH_PAGES -> prefetchPages(
chapter = intent.getParcelableExtraCompat<ParcelableMangaChapters>(EXTRA_CHAPTER)
?.chapters?.singleOrNull() ?: return,
chapter = intent.getParcelableExtraCompat<ParcelableChapter>(EXTRA_CHAPTER)?.chapter
?: return,
)
ACTION_PREFETCH_LAST -> prefetchLast()
@@ -71,7 +73,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val chapter = if (history == null) {
chapters.firstOrNull()
} else {
chapters.find { x -> x.id == history.chapterId } ?: chapters.firstOrNull()
chapters.findById(history.chapterId) ?: chapters.firstOrNull()
} ?: return
runCatchingCancellable { repo.getPages(chapter) }
}
@@ -88,7 +90,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, manga.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent)
}
@@ -96,7 +98,7 @@ class MangaPrefetchService : CoroutineIntentService() {
if (!isPrefetchAvailable(context, chapter.source)) return
val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableMangaChapters(listOf(chapter)))
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try {
context.startService(intent)
} catch (e: IllegalStateException) {
@@ -119,7 +121,10 @@ class MangaPrefetchService : CoroutineIntentService() {
if (context.isPowerSaveMode()) {
return false
}
val entryPoint = EntryPointAccessors.fromApplication(context, PrefetchCompanionEntryPoint::class.java)
val entryPoint = EntryPointAccessors.fromApplication(
context,
PrefetchCompanionEntryPoint::class.java,
)
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
}
}

View File

@@ -369,7 +369,7 @@ class DetailsActivity :
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {

View File

@@ -43,6 +43,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -247,11 +248,7 @@ class DetailsFragment :
}
private fun onLoadingStateChanged(isLoading: Boolean) {
if (isLoading) {
requireViewBinding().progressBar.show()
} else {
requireViewBinding().progressBar.hide()
}
requireViewBinding().progressBar.showOrHide(isLoading)
}
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {

View File

@@ -42,6 +42,7 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -87,7 +88,8 @@ class DetailsViewModel @Inject constructor(
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private val doubleManga: MutableStateFlow<DoubleManga?> = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val doubleManga: MutableStateFlow<DoubleManga?> =
MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private var loadingJob: Job
val onShowToast = MutableEventFlow<Int>()
@@ -202,7 +204,14 @@ class DetailsViewModel @Inject constructor(
bookmarks,
networkState,
) { manga, history, branch, news, bookmarks, isOnline ->
mapChapters(manga?.remote?.takeIf { isOnline }, manga?.local, history, news, branch, bookmarks)
mapChapters(
manga?.remote?.takeIf { isOnline },
manga?.local,
history,
news,
branch,
bookmarks,
)
},
isChaptersReversed,
chaptersQuery,
@@ -324,12 +333,15 @@ class DetailsViewModel @Inject constructor(
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
val result = doubleMangaLoadUseCase(intent)
val manga = result.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
doubleManga.value = result
doubleMangaLoadUseCase.invoke(intent)
.onFirst {
val manga = it.requireAny()
// find default branch
val hist = historyRepository.getOne(manga)
selectedBranch.value = manga.getPreferredBranch(hist)
}.collect {
doubleManga.value = it
}
}
private fun List<ChapterListItem>.filterSearch(query: String): List<ChapterListItem> {

View File

@@ -45,6 +45,6 @@ class RelatedMangaActivity : BaseActivity<ActivityContainerBinding>(), AppBarOwn
companion object {
fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed, withChapters = false))
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.explore.data
import androidx.room.withTransaction
import dagger.Reusable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
@@ -11,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections
import java.util.EnumSet
import javax.inject.Inject
@@ -76,14 +77,6 @@ class MangaSourcesRepository @Inject constructor(
}
}
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction {
for (s in sources) {
dao.setEnabled(s.name, isEnabled)
}
}
}
suspend fun disableAllSources() {
db.withTransaction {
assimilateNewSources()
@@ -99,46 +92,20 @@ class MangaSourcesRepository @Inject constructor(
}
}
suspend fun setPosition(source: MangaSource, index: Int) {
db.withTransaction {
val all = dao.findAll().toMutableList()
val sourceIndex = all.indexOfFirst { x -> x.source == source.name }
if (sourceIndex !in all.indices) {
val entity = MangaSourceEntity(
source = source.name,
isEnabled = false,
sortKey = index,
)
all.add(index, entity)
dao.upsert(entity)
} else {
all.move(sourceIndex, index)
}
for ((i, e) in all.withIndex()) {
if (e.sortKey != i) {
dao.setSortKey(e.source, i)
}
}
}
}
fun observeNewSources(): Flow<Set<MangaSource>> = dao.observeAll().map { entities ->
fun observeNewSources(): Flow<Set<MangaSource>> = combine(
dao.observeAll(),
observeIsNsfwDisabled(),
) { entities, skipNsfw ->
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
if (skipNsfw) {
result.removeAll { x -> x.isNsfw() }
}
result
}.distinctUntilChanged()
suspend fun getNewSources(): Set<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources()
if (new.isEmpty()) {
@@ -153,6 +120,9 @@ class MangaSourcesRepository @Inject constructor(
)
}
dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) {
new.removeAll { x -> x.isNsfw() }
}
return new
}
@@ -160,6 +130,15 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty()
}
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size)
for (entity in this) {

View File

@@ -3,8 +3,11 @@ package org.koitharu.kotatsu.favourites.ui.categories
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -25,7 +28,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
private var reorderJob: Job? = null
private var commitJob: Job? = null
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
@@ -33,7 +36,7 @@ class FavouritesCategoriesViewModel @Inject constructor(
launchJob(Dispatchers.Default) {
repository.observeCategoriesWithCovers()
.collectLatest {
reorderJob?.join()
commitJob?.join()
updateContent(it)
}
}
@@ -52,17 +55,10 @@ class FavouritesCategoriesViewModel @Inject constructor(
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
fun reorderCategories(oldPos: Int, newPos: Int) {
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
prevJob?.join()
val snapshot = content.requireValue().toMutableList()
snapshot.move(oldPos, newPos)
content.value = snapshot
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
}
val snapshot = content.requireValue().toMutableList()
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
}
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
@@ -80,6 +76,19 @@ class FavouritesCategoriesViewModel @Inject constructor(
}
}
private fun commit(snapshot: List<ListModel>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
(it as? CategoryListModel)?.category?.id
}
repository.reorderCategories(ids)
yield()
}
}
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
content.value = categories.map { (category, covers) ->
CategoryListModel(

View File

@@ -76,10 +76,7 @@ class FavouriteSheet :
putParcelableArrayList(
KEY_MANGA_LIST,
manga.mapTo(ArrayList(manga.size)) {
ParcelableManga(
it,
withChapters = false,
)
ParcelableManga(it)
},
)
}.showDistinct(fm, TAG)

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
@@ -107,7 +108,7 @@ class HistoryRepository @Inject constructor(
),
)
trackingRepository.syncWithHistory(manga, chapterId)
val chapter = manga.chapters?.find { x -> x.id == chapterId }
val chapter = manga.chapters?.findById(chapterId)
if (chapter != null) {
scrobblers.forEach { it.tryScrobble(manga.id, chapter) }
}
@@ -181,7 +182,7 @@ class HistoryRepository @Inject constructor(
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.any { it.id == chapterId }) {
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
return this
}
val newChapterId = chapters.getOrNull(

View File

@@ -25,4 +25,5 @@ enum class ListItemType {
DOWNLOAD,
CATEGORY_LARGE,
MANGA_SCROBBLING,
NAV_ITEM,
}

View File

@@ -13,34 +13,39 @@ class TypedListSpacingDecoration(
) : ItemDecoration() {
private val spacingSmall = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_small)
private val spacingNormal = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingNormal =
context.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal)
private val spacingLarge = context.resources.getDimensionPixelOffset(R.dimen.list_spacing_large)
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
state: RecyclerView.State,
) {
val itemType = parent.getChildViewHolder(view)?.itemViewType?.let {
ListItemType.entries.getOrNull(it)
}
when (itemType) {
ListItemType.FILTER_SORT,
ListItemType.FILTER_TAG -> outRect.set(0)
ListItemType.FILTER_TAG,
-> outRect.set(0)
ListItemType.HEADER,
ListItemType.FEED,
ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST -> outRect.set(0)
ListItemType.MANGA_LIST,
-> outRect.set(0)
ListItemType.DOWNLOAD,
ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED -> outRect.set(spacingNormal)
ListItemType.MANGA_LIST_DETAILED,
-> outRect.set(spacingNormal)
ListItemType.PAGE_THUMB,
ListItemType.MANGA_GRID -> outRect.set(spacingNormal)
ListItemType.MANGA_GRID,
-> outRect.set(spacingNormal)
ListItemType.EXPLORE_BUTTONS -> outRect.set(spacingNormal)
@@ -53,7 +58,9 @@ class TypedListSpacingDecoration(
ListItemType.EXPLORE_SUGGESTION,
ListItemType.MANGA_NESTED_GROUP,
ListItemType.CATEGORY_LARGE,
null -> outRect.set(0)
ListItemType.NAV_ITEM,
null,
-> outRect.set(0)
ListItemType.TIP -> outRect.set(0) // TODO
}
@@ -70,6 +77,6 @@ class TypedListSpacingDecoration(
private fun Rect.set(spacing: Int) = set(spacing, spacing, spacing, spacing)
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
}

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.zip.ZipOutput
@@ -87,7 +88,7 @@ class LocalMangaDirOutput(
suspend fun deleteChapter(chapterId: Long) {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found"
}.first { it.id == chapterId }
}.findById(chapterId) ?: error("Chapter not found")
val chapterDir = File(rootFile, chapterFileName(chapter))
chapterDir.deleteAwait()
index.removeChapter(chapterId)

View File

@@ -106,7 +106,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
return
}
val intent = Intent(context, LocalChaptersRemoveService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
}

View File

@@ -47,12 +47,20 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
override fun onScrolledToEnd() = viewModel.loadNextPage()
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_local, menu)
return super.onCreateActionMode(controller, mode, menu)
}
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode, item: MenuItem): Boolean {
override fun onActionItemClicked(
controller: ListSelectionController,
mode: ActionMode,
item: MenuItem,
): Boolean {
return when (item.itemId) {
R.id.action_remove -> {
showDeletionConfirm(selectedItemsIds, mode)
@@ -83,13 +91,20 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
}
private fun onItemRemoved() {
Snackbar.make(requireViewBinding().recyclerView, R.string.removal_completed, Snackbar.LENGTH_SHORT).show()
Snackbar.make(
requireViewBinding().recyclerView,
R.string.removal_completed,
Snackbar.LENGTH_SHORT
).show()
}
companion object {
fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable(RemoteListFragment.ARG_SOURCE, MangaSource.LOCAL) // required by FilterCoordinator
putSerializable(
RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL
) // required by FilterCoordinator
}
}
}

View File

@@ -8,6 +8,7 @@ import coil.request.ImageResult
import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -92,7 +93,7 @@ class CoverRestoreInterceptor @Inject constructor(
private suspend fun restoreBookmarkImpl(bookmark: Bookmark): Boolean {
val repo = repositoryFactory.create(bookmark.manga.source) as? RemoteMangaRepository ?: return false
val chapter = repo.getDetails(bookmark.manga).chapters?.find { it.id == bookmark.chapterId } ?: return false
val chapter = repo.getDetails(bookmark.manga).chapters?.findById(bookmark.chapterId) ?: return false
val page = repo.getPages(chapter)[bookmark.page]
val imageUrl = page.preview.ifNullOrEmpty { page.url }
return if (imageUrl != bookmark.imageUrl) {

View File

@@ -37,6 +37,7 @@ import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper
@@ -71,8 +72,10 @@ import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search"
@AndroidEntryPoint
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner, View.OnClickListener,
View.OnFocusChangeListener, SearchSuggestionListener, MainNavigationDelegate.OnFragmentChangedListener {
class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNavOwner,
View.OnClickListener,
View.OnFocusChangeListener, SearchSuggestionListener,
MainNavigationDelegate.OnFragmentChangedListener {
@Inject
lateinit var settings: AppSettings
@@ -119,7 +122,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
settings = settings,
)
navigationDelegate.addOnFragmentChangedListener(this)
navigationDelegate.onCreate(savedInstanceState)
navigationDelegate.onCreate(this, savedInstanceState)
appUpdateBadge = OptionsMenuBadgeHelper(viewBinding.toolbar, R.id.action_app_update)
@@ -137,8 +140,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this))
viewModel.onFirstStart.observeEvent(this) { OnboardDialogFragment.showWelcome(supportFragmentManager) }
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
viewModel.onFirstStart.observeEvent(this) {
OnboardDialogFragment.show(supportFragmentManager)
}
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
}
@@ -166,7 +170,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
if (menu == null) {
return false
}
menu.findItem(R.id.action_incognito)?.isChecked = searchSuggestionViewModel.isIncognitoModeEnabled.value
menu.findItem(R.id.action_incognito)?.isChecked =
searchSuggestionViewModel.isIncognitoModeEnabled.value
val hasAppUpdate = viewModel.appUpdate.value != null
menu.findItem(R.id.action_app_update)?.isVisible = hasAppUpdate
appUpdateBadge.setBadgeVisible(hasAppUpdate)
@@ -279,17 +284,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
startActivity(IntentBuilder(this).manga(manga).build(), options)
}
private fun onCountersChanged(counters: IntArray) {
repeat(counters.size) { i ->
val counter = counters[i]
navigationDelegate.setCounterAt(i, counter)
private fun onCountersChanged(counters: Map<NavItem, Int>) {
counters.forEach { (navItem, counter) ->
navigationDelegate.setCounter(navItem, counter)
}
}
private fun onFeedAvailabilityChanged(isFeedAvailable: Boolean) {
navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable)
}
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = viewBinding.searchView.imeOptions
options = if (isIncognito) {
@@ -362,8 +362,12 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
} else {
SCROLL_FLAG_SCROLL or SCROLL_FLAG_ENTER_ALWAYS or SCROLL_FLAG_SNAP
}
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> { scrollFlags = appBarScrollFlags }
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> { scrollFlags = appBarScrollFlags }
viewBinding.toolbarCard.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.insetsHolder.updateLayoutParams<AppBarLayout.LayoutParams> {
scrollFlags = appBarScrollFlags
}
viewBinding.toolbarCard.background = if (isOpened) {
null
} else {
@@ -387,7 +391,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
Manifest.permission.POST_NOTIFICATIONS,
) != PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
}

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.main.ui
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.annotation.IdRes
@@ -8,16 +9,28 @@ import androidx.core.view.isEmpty
import androidx.core.view.iterator
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList
@@ -62,11 +75,11 @@ class MainNavigationDelegate(
navBar.selectedItemId = R.id.nav_history
}
fun onCreate(savedInstanceState: Bundle?) {
fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) {
if (navBar.menu.isEmpty()) {
val menuRes = if (settings.isFavoritesNavItemFirst) R.menu.nav_bottom_alt else R.menu.nav_bottom
navBar.inflateMenu(menuRes)
createMenu(settings.mainNavItems, navBar.menu)
}
observeSettings(lifecycleOwner)
val fragment = primaryFragment
if (fragment != null) {
onFragmentChanged(fragment, fromUser = false)
@@ -84,12 +97,11 @@ class MainNavigationDelegate(
}
}
fun setCounterAt(position: Int, counter: Int) {
val id = navBar.menu.getItem(position).itemId
setCounter(id, counter)
fun setCounter(item: NavItem, counter: Int) {
setCounter(item.id, counter)
}
fun setCounter(@IdRes id: Int, counter: Int) {
private fun setCounter(@IdRes id: Int, counter: Int) {
if (counter == 0) {
navBar.getBadge(id)?.isVisible = false
} else {
@@ -123,9 +135,12 @@ class MainNavigationDelegate(
return setPrimaryFragment(
when (itemId) {
R.id.nav_history -> HistoryListFragment()
R.id.nav_favourites -> FavouritesContainerFragment()
R.id.nav_favorites -> FavouritesContainerFragment()
R.id.nav_explore -> ExploreFragment()
R.id.nav_feed -> FeedFragment()
R.id.nav_local -> LocalListFragment.newInstance()
R.id.nav_suggestions -> SuggestionsFragment()
R.id.nav_bookmarks -> BookmarksFragment()
else -> return false
},
)
@@ -133,9 +148,12 @@ class MainNavigationDelegate(
private fun getItemId(fragment: Fragment) = when (fragment) {
is HistoryListFragment -> R.id.nav_history
is FavouritesContainerFragment -> R.id.nav_favourites
is FavouritesContainerFragment -> R.id.nav_favorites
is ExploreFragment -> R.id.nav_explore
is FeedFragment -> R.id.nav_feed
is LocalListFragment -> R.id.nav_local
is SuggestionsFragment -> R.id.nav_suggestions
is BookmarksFragment -> R.id.nav_bookmarks
else -> 0
}
@@ -157,6 +175,24 @@ class MainNavigationDelegate(
listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
}
private fun createMenu(items: List<NavItem>, menu: Menu) {
for (item in items) {
menu.add(Menu.NONE, item.id, Menu.NONE, item.title)
.setIcon(item.icon)
}
}
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
settings.observe()
.filter { x -> x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS }
.onStart { emit("") }
.flowOn(Dispatchers.Default)
.onEach {
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
}.launchIn(lifecycleOwner.lifecycleScope)
}
private fun firstItem(): MenuItem? {
val menu = navBar.menu
for (item in menu) {

View File

@@ -12,7 +12,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.main.domain.ReadingResumeEnabledUseCase
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import java.util.EnumMap
import javax.inject.Inject
@HiltViewModel
@@ -42,23 +43,20 @@ class MainViewModel @Inject constructor(
initialValue = false,
)
val isFeedAvailable = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val appUpdate = appUpdateRepository.observeAvailableUpdate()
val counters = combine(
trackingRepository.observeUpdatedMangaCount(),
observeNewSourcesCount(),
) { tracks, newSources ->
intArrayOf(0, 0, newSources, tracks)
val em = EnumMap<NavItem, Int>(NavItem::class.java)
em[NavItem.EXPLORE] = newSources
em[NavItem.FEED] = tracks
em
}.stateIn(
scope = viewModelScope + Dispatchers.Default,
started = SharingStarted.WhileSubscribed(5000),
initialValue = IntArray(4),
initialValue = emptyMap<NavItem, Int>(),
)
init {

View File

@@ -2,6 +2,12 @@ package org.koitharu.kotatsu.reader.domain
import android.util.LongSparseArray
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -17,24 +23,32 @@ class ChaptersLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
private val chapters = LongSparseArray<MangaChapter>()
private val chapters = MutableStateFlow(LongSparseArray<MangaChapter>(0))
private val chapterPages = ChapterPages()
private val mutex = Mutex()
val size: Int
get() = chapters.size()
val size: Int // TODO flow
get() = chapters.value.size()
suspend fun init(manga: DoubleManga) = mutex.withLock {
chapters.clear()
manga.chapters?.forEach {
chapters.put(it.id, it)
fun init(scope: CoroutineScope, manga: Flow<DoubleManga>) = scope.launch {
manga.collect {
val ch = it.chapters.orEmpty()
val longSparseArray = LongSparseArray<MangaChapter>(ch.size)
ch.forEach { x -> longSparseArray.put(x.id, x) }
mutex.withLock {
chapters.value = longSparseArray
}
}
}
suspend fun loadPrevNextChapter(manga: DoubleManga, currentId: Long, isNext: Boolean) {
val chapters = manga.chapters ?: return
val predicate: (MangaChapter) -> Boolean = { it.id == currentId }
val index = if (isNext) chapters.indexOfFirst(predicate) else chapters.indexOfLast(predicate)
val index = if (isNext) {
chapters.indexOfFirst(predicate)
} else {
chapters.indexOfLast(predicate)
}
if (index == -1) return
val newChapter = chapters.getOrNull(if (isNext) index + 1 else index - 1) ?: return
val newPages = loadChapter(newChapter.id)
@@ -65,7 +79,11 @@ class ChaptersLoader @Inject constructor(
}
}
fun peekChapter(chapterId: Long): MangaChapter? = chapters[chapterId]
fun peekChapter(chapterId: Long): MangaChapter? = chapters.value[chapterId]
suspend fun awaitChapter(chapterId: Long): MangaChapter? = chapters.mapNotNull { x ->
x[chapterId]
}.firstOrNull()
fun getPages(chapterId: Long): List<ReaderPage> {
return chapterPages.subList(chapterId)
@@ -82,7 +100,7 @@ class ChaptersLoader @Inject constructor(
fun snapshot() = chapterPages.toList()
private suspend fun loadChapter(chapterId: Long): List<ReaderPage> {
val chapter = checkNotNull(chapters[chapterId]) { "Requested chapter not found" }
val chapter = checkNotNull(awaitChapter(chapterId)) { "Requested chapter not found" }
val repo = mangaRepositoryFactory.create(chapter.source)
return repo.getPages(chapter).mapIndexed { index, page ->
ReaderPage(page, index, chapterId)

View File

@@ -5,16 +5,14 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaChapters
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.getParcelableCompat
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
@@ -24,23 +22,29 @@ import javax.inject.Inject
import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClickListener<ChapterListItem> {
class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(),
OnListItemClickListener<ChapterListItem> {
@Inject
lateinit var settings: AppSettings
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersBinding {
private val viewModel: ReaderViewModel by activityViewModels()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): SheetChaptersBinding {
return SheetChaptersBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetChaptersBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val chapters = arguments?.getParcelableCompat<ParcelableMangaChapters>(ARG_CHAPTERS)?.chapters
val chapters = viewModel.manga?.chapters
if (chapters.isNullOrEmpty()) {
dismissAllowingStateLoss()
return
}
val currentId = requireArguments().getLong(ARG_CURRENT_ID, 0L)
val currentId = viewModel.getCurrentState()?.chapterId ?: 0L
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val items = chapters.mapIndexed { index, chapter ->
chapter.toListItem(
@@ -54,8 +58,11 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset))
val offset =
(resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(
items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset)
)
} else {
adapter.items = items
}
@@ -63,7 +70,8 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
}
override fun onItemClick(item: ChapterListItem, view: View) {
((parentFragment as? OnChapterChangeListener) ?: (activity as? OnChapterChangeListener))?.let {
((parentFragment as? OnChapterChangeListener)
?: (activity as? OnChapterChangeListener))?.let {
dismiss()
it.onChapterChanged(item.chapter)
}
@@ -76,18 +84,8 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(), OnListItemClick
companion object {
private const val ARG_CHAPTERS = "chapters"
private const val ARG_CURRENT_ID = "current_id"
private const val TAG = "ChaptersBottomSheet"
fun show(
fm: FragmentManager,
chapters: List<MangaChapter>,
currentId: Long,
) = ChaptersSheet().withArgs(2) {
putParcelable(ARG_CHAPTERS, ParcelableMangaChapters(chapters))
putLong(ARG_CURRENT_ID, currentId)
}.showDistinct(fm, TAG)
fun show(fm: FragmentManager) = ChaptersSheet().showDistinct(fm, TAG)
}
}

View File

@@ -181,11 +181,7 @@ class ReaderActivity :
}
R.id.action_chapters -> {
ChaptersSheet.show(
supportFragmentManager,
viewModel.manga?.chapters.orEmpty(),
viewModel.getCurrentState()?.chapterId ?: 0L,
)
ChaptersSheet.show(supportFragmentManager)
}
R.id.action_pages_thumbs -> {
@@ -404,7 +400,7 @@ class ReaderActivity :
.setAction(ACTION_MANGA_READ)
fun manga(manga: Manga) = apply {
intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = true))
intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun mangaId(mangaId: Long) = apply {

View File

@@ -87,7 +87,8 @@ class ReaderViewModel @Inject constructor(
private var pageSaveJob: Job? = null
private var bookmarkJob: Job? = null
private var stateChangeJob: Job? = null
private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val currentState =
MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val mangaFlow: Flow<Manga?>
get() = mangaData.map { it?.any }
@@ -317,8 +318,9 @@ class ReaderViewModel @Inject constructor(
?: throw NotFoundException("Cannot find manga", ""),
)
mangaData.value = manga
manga = doubleMangaLoadUseCase(intent)
chaptersLoader.init(manga)
val mangaFlow = doubleMangaLoadUseCase(intent)
manga = mangaFlow.first { x -> x.any != null }
chaptersLoader.init(viewModelScope, mangaFlow)
// determine mode
val singleManga = manga.requireAny()
// obtain state
@@ -328,7 +330,7 @@ class ReaderViewModel @Inject constructor(
} ?: ReaderState(singleManga, preselectedBranch)
}
val mode = detectReaderModeUseCase.invoke(singleManga, currentState.value)
val branch = chaptersLoader.peekChapter(currentState.value?.chapterId ?: 0L)?.branch
val branch = chaptersLoader.awaitChapter(currentState.value?.chapterId ?: 0L)?.branch
mangaData.value = manga.filterChapters(branch)
readerMode.value = mode

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong
private const val MAX_DELAY = 20L
private const val MAX_DELAY = 8L
private const val MAX_SWITCH_DELAY = 10_000L
private const val INTERACTION_SKIP_MS = 2_000L
private const val SPEED_FACTOR_DELTA = 0.02f

View File

@@ -152,7 +152,7 @@ class ColorFilterConfigActivity :
fun newIntent(context: Context, manga: Manga, page: MangaPage) =
Intent(context, ColorFilterConfigActivity::class.java)
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
.putExtra(EXTRA_MANGA, ParcelableManga(manga))
.putExtra(EXTRA_PAGES, ParcelableMangaPage(page))
}
}

View File

@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context
import android.graphics.PointF
import android.util.AttributeSet
import androidx.core.view.ancestors
import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.parsers.util.toIntUp
private const val SCROLL_UNKNOWN = -1
@@ -93,7 +93,7 @@ class WebtoonImageView @JvmOverloads constructor(
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return
computeScrollRange()
val container = parents.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return
val container = ancestors.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return
val parentHeight = parentHeight()
if (scrollPos != 0 && container.bottom < parentHeight) {
scrollTo(scrollRange)
@@ -115,6 +115,6 @@ class WebtoonImageView @JvmOverloads constructor(
}
private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
}
}

View File

@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
@@ -84,6 +85,7 @@ class PagesThumbnailsSheet :
viewModel.thumbnails.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.branch.observe(viewLifecycleOwner, ::updateTitle)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.isLoading.observe(viewLifecycleOwner) { binding.progressBar.showOrHide(it) }
}
override fun onDestroyView() {
@@ -190,7 +192,7 @@ class PagesThumbnailsSheet :
fun show(fm: FragmentManager, manga: Manga, chapterId: Long, currentPage: Int = -1) {
PagesThumbnailsSheet().withArgs(3) {
putParcelable(ARG_MANGA, ParcelableManga(manga, withChapters = true))
putParcelable(ARG_MANGA, ParcelableManga(manga))
putLong(ARG_CHAPTER_ID, chapterId)
putInt(ARG_CURRENT_PAGE, currentPage)
}.showDistinct(fm, TAG)

View File

@@ -1,19 +1,26 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.firstNotNullOrNull
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
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.parsers.util.SuspendLazy
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import javax.inject.Inject
@@ -30,13 +37,12 @@ class PagesThumbnailsViewModel @Inject constructor(
val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga
private val repository = mangaRepositoryFactory.create(manga.source)
private val mangaDetails = SuspendLazy {
doubleMangaLoadUseCase(manga).let {
val b = manga.chapters?.find { ch -> ch.id == initialChapterId }?.branch
branch.value = b
it.filterChapters(b)
}
}
private val mangaDetails = doubleMangaLoadUseCase(manga).map {
val b = manga.chapters?.findById(initialChapterId)?.branch
branch.value = b
it.filterChapters(b)
}.withErrorHandling()
.stateIn(viewModelScope, SharingStarted.Lazily, null)
private var loadingJob: Job? = null
private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null
@@ -46,8 +52,9 @@ class PagesThumbnailsViewModel @Inject constructor(
val branch = MutableStateFlow<String?>(null)
init {
loadingJob = launchJob(Dispatchers.Default) {
chaptersLoader.init(mangaDetails.get())
loadingJob = launchLoadingJob(Dispatchers.Default) {
chaptersLoader.init(viewModelScope, mangaDetails.filterNotNull())
mangaDetails.first { x -> x?.hasChapter(initialChapterId) == true }
chaptersLoader.loadSingleChapter(initialChapterId)
updateList()
}
@@ -55,7 +62,7 @@ class PagesThumbnailsViewModel @Inject constructor(
fun allowLoadAbove() {
if (!isLoadAboveAllowed) {
loadingJob = launchJob(Dispatchers.Default) {
loadingJob = launchLoadingJob(Dispatchers.Default) {
isLoadAboveAllowed = true
updateList()
}
@@ -78,23 +85,18 @@ class PagesThumbnailsViewModel @Inject constructor(
private fun loadPrevNextChapter(isNext: Boolean): Job = launchLoadingJob(Dispatchers.Default) {
val currentId = (if (isNext) chaptersLoader.last() else chaptersLoader.first()).chapterId
chaptersLoader.loadPrevNextChapter(mangaDetails.get(), currentId, isNext)
chaptersLoader.loadPrevNextChapter(mangaDetails.firstNotNull(), currentId, isNext)
updateList()
}
private suspend fun updateList() {
val snapshot = chaptersLoader.snapshot()
val mangaChapters = mangaDetails.tryGet().getOrNull()?.chapters.orEmpty()
val hasPrevChapter = isLoadAboveAllowed && snapshot.firstOrNull()?.chapterId != mangaChapters.firstOrNull()?.id
val hasNextChapter = snapshot.lastOrNull()?.chapterId != mangaChapters.lastOrNull()?.id
val mangaChapters = mangaDetails.firstNotNullOrNull()?.chapters.orEmpty()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
if (hasPrevChapter) {
add(LoadingFooter(-1))
}
var previousChapterId = 0L
for (page in snapshot) {
if (page.chapterId != previousChapterId) {
chaptersLoader.peekChapter(page.chapterId)?.let {
chaptersLoader.awaitChapter(page.chapterId)?.let {
add(ListHeader(it.name))
}
previousChapterId = page.chapterId
@@ -105,9 +107,6 @@ class PagesThumbnailsViewModel @Inject constructor(
page = page,
)
}
if (hasNextChapter) {
add(LoadingFooter(1))
}
}
thumbnails.value = pages
}

View File

@@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
@@ -22,7 +21,6 @@ class PageThumbnailAdapter(
init {
addDelegate(ListItemType.PAGE_THUMB, pageThumbnailAD(coil, lifecycleOwner, clickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(null))
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
}
override fun getSectionText(context: Context, position: Int): CharSequence? {

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -115,13 +116,7 @@ class ScrobblerConfigActivity : BaseActivity<ActivityScrobblerConfigBinding>(),
}
private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.progressBar.run {
if (isLoading) {
show()
} else {
hide()
}
}
viewBinding.progressBar.showOrHide(isLoading)
}
private fun showUserDialog() {

View File

@@ -204,7 +204,7 @@ class ScrobblingSelectorSheet :
fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) =
ScrobblingSelectorSheet().withArgs(2) {
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga, withChapters = false))
putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga))
if (scrobblerService != null) {
putInt(ARG_SCROBBLER, scrobblerService.id)
}

View File

@@ -102,7 +102,7 @@ class MangaListActivity :
fun showPreview(manga: Manga): Boolean = setSideFragment(
PreviewFragment::class.java,
bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga, true)),
bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga)),
)
fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null)

View File

@@ -32,7 +32,8 @@ class SearchEditText @JvmOverloads constructor(
) : AppCompatEditText(context, attrs, defStyleAttr) {
var searchSuggestionListener: SearchSuggestionListener? = null
private val clearIcon = ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
private val clearIcon =
ContextCompat.getDrawable(context, materialR.drawable.abc_ic_clear_material)
private var isEmpty = text.isNullOrEmpty()
init {
@@ -52,12 +53,21 @@ class SearchEditText @JvmOverloads constructor(
if (keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
if (hasFocus()) {
clearFocus()
// return true
}
}
return super.onKeyPreIme(keyCode, event)
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers() && query.isNotEmpty()) {
cancelLongPress()
searchSuggestionListener?.onQueryClick(query, submit = true)
clearFocus()
return true
}
return super.onKeyUp(keyCode, event)
}
override fun onEditorAction(actionCode: Int) {
super.onEditorAction(actionCode)
if (actionCode == EditorInfo.IME_ACTION_SEARCH) {
@@ -88,7 +98,8 @@ class SearchEditText @JvmOverloads constructor(
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_UP) {
val drawable = compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event)
val drawable =
compoundDrawablesRelative[DRAWABLE_END] ?: return super.onTouchEvent(event)
val isOnDrawable = drawable.isVisible && if (layoutDirection == LAYOUT_DIRECTION_RTL) {
event.x.toInt() in paddingLeft..(drawable.bounds.width() + paddingLeft)
} else {

View File

@@ -23,7 +23,6 @@ import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.toList
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.utils.ActivityListPreference
@@ -67,6 +66,7 @@ class AppearanceSettingsFragment :
}
setDefaultValueCompat("")
}
bindNavSummary()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -86,7 +86,8 @@ class AppearanceSettingsFragment :
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED -> {
AppSettings.KEY_THEME_AMOLED,
-> {
postRestart()
}
@@ -94,8 +95,8 @@ class AppearanceSettingsFragment :
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
AppSettings.KEY_FIRST_NAV_ITEM -> {
activityRecreationHandle.recreate(MainActivity::class.java)
AppSettings.KEY_NAV_MAIN -> {
bindNavSummary()
}
}
}
@@ -127,6 +128,13 @@ class AppearanceSettingsFragment :
}
}
private fun bindNavSummary() {
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
pref.summary = settings.mainNavItems.joinToString {
getString(it.title)
}
}
private class LocaleComparator(context: Context) : Comparator<Locale> {
private val deviceLocales = LocaleManagerCompat.getSystemLocales(context)

View File

@@ -0,0 +1,136 @@
package org.koitharu.kotatsu.settings.nav
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.settings.nav.adapter.navAddAD
import org.koitharu.kotatsu.settings.nav.adapter.navAvailableAD
import org.koitharu.kotatsu.settings.nav.adapter.navConfigAD
@AndroidEntryPoint
class NavConfigFragment : BaseFragment<FragmentSettingsSourcesBinding>(), RecyclerViewOwner,
OnListItemClickListener<NavItem>, View.OnClickListener {
private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModels<NavConfigViewModel>()
override val recyclerView: RecyclerView
get() = requireViewBinding().recyclerView
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): FragmentSettingsSourcesBinding {
return FragmentSettingsSourcesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(
binding: FragmentSettingsSourcesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
val navConfigAdapter = BaseListAdapter<ListModel>()
.addDelegate(ListItemType.NAV_ITEM, navConfigAD(this))
.addDelegate(ListItemType.FOOTER_LOADING, navAddAD(this))
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = navConfigAdapter
reorderHelper = ItemTouchHelper(ReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.content.observe(viewLifecycleOwner, navConfigAdapter)
}
override fun onResume() {
super.onResume()
activity?.setTitle(R.string.main_screen_sections)
}
override fun onDestroyView() {
reorderHelper = null
super.onDestroyView()
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().recyclerView.updatePadding(
bottom = insets.bottom,
left = insets.left,
right = insets.right,
)
}
override fun onClick(v: View) {
var dialog: DialogInterface? = null
val listener = OnListItemClickListener<NavItem> { item, _ ->
viewModel.addItem(item)
dialog?.dismiss()
}
dialog = RecyclerViewAlertDialog.Builder<NavItem>(v.context)
.setTitle(R.string.add)
.addAdapterDelegate(navAvailableAD(listener))
.setCancelable(true)
.setItems(viewModel.availableItems)
.setNegativeButton(android.R.string.cancel, null)
.create()
.apply { show() }
}
override fun onItemClick(item: NavItem, view: View) {
viewModel.removeItem(item)
}
override fun onItemLongClick(item: NavItem, view: View): Boolean {
val holder = viewBinding?.recyclerView?.findContainingViewHolder(view) ?: return false
reorderHelper?.startDrag(holder)
return true
}
private inner class ReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = target.itemViewType == ListItemType.NAV_ITEM.ordinal
override fun onMoved(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
fromPos: Int,
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorder(fromPos, toPos)
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
}

View File

@@ -0,0 +1,84 @@
package org.koitharu.kotatsu.settings.nav
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel
import javax.inject.Inject
@HiltViewModel
class NavConfigViewModel @Inject constructor(
private val settings: AppSettings,
private val activityRecreationHandle: ActivityRecreationHandle,
) : BaseViewModel() {
private val items = MutableStateFlow(settings.mainNavItems)
val content: StateFlow<List<ListModel>> = items.map { snapshot ->
if (snapshot.size < NavItem.entries.size) {
snapshot + NavItemAddModel(snapshot.size < 5)
} else {
snapshot
}
}.stateIn(
viewModelScope + Dispatchers.Default,
SharingStarted.WhileSubscribed(5000),
emptyList()
)
private var commitJob: Job? = null
val availableItems
get() = items.value.let { snapshot ->
NavItem.entries.filterNot { x -> x in snapshot }
}
fun reorder(fromPos: Int, toPos: Int) {
items.value = items.value.toMutableList().apply {
move(fromPos, toPos)
commit(this)
}
}
fun addItem(item: NavItem) {
items.value = items.value.plus(item).also {
commit(it)
}
}
fun removeItem(item: NavItem) {
val newList = items.value.toMutableList()
newList.remove(item)
if (newList.isEmpty()) {
newList.add(NavItem.EXPLORE)
}
items.value = newList
commit(newList)
}
private fun commit(value: List<NavItem>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
settings.mainNavItems = value
activityRecreationHandle.recreate(MainActivity::class.java)
}
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.settings.nav.adapter
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemNavAvailableBinding
import org.koitharu.kotatsu.databinding.ItemNavConfigBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel
@SuppressLint("ClickableViewAccessibility")
fun navConfigAD(
clickListener: OnListItemClickListener<NavItem>,
) = adapterDelegateViewBinding<NavItem, ListModel, ItemNavConfigBinding>(
{ layoutInflater, parent -> ItemNavConfigBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = object : View.OnClickListener, View.OnTouchListener {
override fun onClick(v: View) = clickListener.onItemClick(item, v)
override fun onTouch(v: View?, event: MotionEvent): Boolean =
event.actionMasked == MotionEvent.ACTION_DOWN &&
clickListener.onItemLongClick(item, itemView)
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewReorder.setOnTouchListener(eventListener)
bind {
with(binding.textViewTitle) {
setText(item.title)
setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0)
}
}
}
fun navAvailableAD(
clickListener: OnListItemClickListener<NavItem>,
) = adapterDelegateViewBinding<NavItem, NavItem, ItemNavAvailableBinding>(
{ layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
clickListener.onItemClick(item, v)
}
bind {
with(binding.root) {
setText(item.title)
setCompoundDrawablesRelativeWithIntrinsicBounds(item.icon, 0, 0, 0)
}
}
}
fun navAddAD(
clickListener: View.OnClickListener,
) = adapterDelegateViewBinding<NavItemAddModel, ListModel, ItemNavAvailableBinding>(
{ layoutInflater, parent -> ItemNavAvailableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener(clickListener)
binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_add, 0, 0, 0)
bind {
with(binding.root) {
setText(if (item.canAdd) R.string.add else R.string.items_limit_exceeded)
isEnabled = item.canAdd
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.settings.nav.model
import org.koitharu.kotatsu.list.ui.model.ListModel
data class NavItemAddModel(
val canAdd: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean = other is NavItemAddModel
}

View File

@@ -28,7 +28,10 @@ class NewSourcesDialogFragment :
private val viewModel by viewModels<NewSourcesViewModel>()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogOnboardBinding {
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
): DialogOnboardBinding {
return DialogOnboardBinding.inflate(inflater, container, false)
}
@@ -54,6 +57,8 @@ class NewSourcesDialogFragment :
override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.onItemEnabledChanged(item, isEnabled)
}

View File

@@ -4,7 +4,6 @@ import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -13,7 +12,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
@@ -25,14 +23,6 @@ class OnboardDialogFragment :
DialogInterface.OnClickListener, SourceLocaleListener {
private val viewModel by viewModels<OnboardViewModel>()
private var isWelcome: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.run {
isWelcome = getBoolean(ARG_WELCOME, false)
}
}
override fun onCreateViewBinding(
inflater: LayoutInflater,
@@ -43,11 +33,7 @@ class OnboardDialogFragment :
super.onBuildDialog(builder)
.setPositiveButton(R.string.done, this)
.setCancelable(false)
if (isWelcome) {
builder.setTitle(R.string.welcome)
} else {
builder.setTitle(R.string.remote_sources)
}
builder.setTitle(R.string.welcome)
return builder
}
@@ -55,11 +41,7 @@ class OnboardDialogFragment :
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = SourceLocalesAdapter(this)
binding.recyclerView.adapter = adapter
if (isWelcome) {
binding.textViewTitle.setText(R.string.onboard_text)
} else {
binding.textViewTitle.isVisible = false
}
binding.textViewTitle.setText(R.string.onboard_text)
viewModel.list.observe(viewLifecycleOwner, adapter)
}
@@ -76,14 +58,7 @@ class OnboardDialogFragment :
companion object {
private const val TAG = "OnboardDialog"
private const val ARG_WELCOME = "welcome"
fun show(fm: FragmentManager) = OnboardDialogFragment().show(fm, TAG)
fun showWelcome(fm: FragmentManager) {
OnboardDialogFragment().withArgs(1) {
putBoolean(ARG_WELCOME, true)
}.showAllowStateLoss(fm, TAG)
}
fun show(fm: FragmentManager) = OnboardDialogFragment().showAllowStateLoss(fm, TAG)
}
}

View File

@@ -0,0 +1,186 @@
package org.koitharu.kotatsu.settings.sources
import androidx.core.os.LocaleListCompat
import androidx.room.InvalidationTracker
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_SOURCES
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject
@ViewModelScoped
class SourcesListProducer @Inject constructor(
lifecycle: ViewModelLifecycle,
private val repository: MangaSourcesRepository,
private val settings: AppSettings,
) : InvalidationTracker.Observer(TABLE_SOURCES) {
private val scope = lifecycle.lifecycleScope
private var query: String = ""
private val expanded = HashSet<String?>()
val list = MutableStateFlow(emptyList<SourceConfigItem>())
private var job = scope.launch(Dispatchers.Default) {
list.value = buildList()
}
init {
settings.observe()
.filter { it == AppSettings.KEY_TIPS_CLOSED || it == AppSettings.KEY_DISABLE_NSFW }
.flowOn(Dispatchers.Default)
.onEach { onInvalidated(emptySet()) }
.launchIn(scope)
}
override fun onInvalidated(tables: Set<String>) {
val prevJob = job
job = scope.launch(Dispatchers.Default) {
prevJob.cancelAndJoin()
list.update { buildList() }
}
}
fun setQuery(value: String) {
this.query = value
onInvalidated(emptySet())
}
fun expandCollapse(group: String?) {
if (!expanded.remove(group)) {
expanded.add(group)
}
onInvalidated(emptySet())
}
private suspend fun buildList(): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSources = repository.getEnabledSources()
val isNsfwDisabled = settings.isNsfwContentDisabled
val withTip = settings.isTipEnabled(TIP_REORDER)
val enabledSet = enabledSources.toEnumSet()
if (query.isNotEmpty()) {
return allSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
}
}
val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it in enabledSet) {
KEY_ENABLED
} else {
it.locale
}
}
map.remove(KEY_ENABLED)
val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
if (withTip) {
result += SourceConfigItem.Tip(
TIP_REORDER,
R.drawable.ic_tap_reorder,
R.string.sources_reorder_tip,
)
}
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) {
list.sortWith(comparator)
val isExpanded = key in expanded
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = getLocaleTitle(key),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
}
}
}
return result
}
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
}
}
companion object {
private fun getLocaleTitle(localeKey: String?): String? {
val locale = Locale(localeKey ?: return null)
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
private const val KEY_ENABLED = "!"
const val TIP_REORDER = "src_reorder"
}
}

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter
import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
@@ -56,7 +55,10 @@ class SourcesManageFragment :
container: ViewGroup?,
) = FragmentSettingsSourcesBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentSettingsSourcesBinding, savedInstanceState: Bundle?) {
override fun onViewBindingCreated(
binding: FragmentSettingsSourcesBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner)
with(binding.recyclerView) {
@@ -67,7 +69,10 @@ class SourcesManageFragment :
}
}
viewModel.content.observe(viewLifecycleOwner, sourcesAdapter)
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
viewModel.onActionDone.observeEvent(
viewLifecycleOwner,
ReversibleActionObserver(binding.recyclerView)
)
addMenuProvider(SourcesMenuProvider())
}
@@ -94,6 +99,10 @@ class SourcesManageFragment :
(activity as? SettingsActivity)?.openFragment(fragment, false)
}
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) {
viewModel.bringToTop(item.source)
}
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled)
}
@@ -127,11 +136,6 @@ class SourcesManageFragment :
true
}
R.id.action_locales -> {
OnboardDialogFragment.show(childFragmentManager)
true
}
R.id.action_no_nsfw -> {
settings.isNsfwContentDisabled = !menuItem.isChecked
true
@@ -181,7 +185,7 @@ class SourcesManageFragment :
target: RecyclerView.ViewHolder,
toPos: Int,
x: Int,
y: Int
y: Int,
) {
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
viewModel.reorderSources(fromPos, toPos)
@@ -196,7 +200,10 @@ class SourcesManageFragment :
target.bindingAdapterPosition,
)
override fun getDragDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
override fun getDragDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int {
val item = viewHolder.getItem(SourceConfigItem.SourceItem::class.java)
return if (item != null && item.isDraggable) {
super.getDragDirs(recyclerView, viewHolder)
@@ -205,7 +212,10 @@ class SourcesManageFragment :
}
}
override fun getSwipeDirs(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
override fun getSwipeDirs(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
): Int {
val item = viewHolder.getItem(SourceConfigItem.Tip::class.java)
return if (item != null) {
super.getSwipeDirs(recyclerView, viewHolder)

View File

@@ -1,88 +1,59 @@
package org.koitharu.kotatsu.settings.sources
import androidx.annotation.CheckResult
import androidx.core.os.LocaleListCompat
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getLocaleTitle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.removeObserverAsync
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.map
import org.koitharu.kotatsu.core.util.ext.toEnumSet
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
import java.util.Locale
import java.util.TreeMap
import javax.inject.Inject
private const val KEY_ENABLED = "!"
private const val TIP_REORDER = "src_reorder"
@HiltViewModel
class SourcesManageViewModel @Inject constructor(
private val database: MangaDatabase,
private val settings: AppSettings,
private val repository: MangaSourcesRepository,
private val listProducer: SourcesListProducer,
) : BaseViewModel() {
private val expandedGroups = MutableStateFlow(emptySet<String?>())
private var searchQuery = MutableStateFlow<String?>(null)
private var reorderJob: Job? = null
val content = MutableStateFlow<List<SourceConfigItem>>(emptyList())
val content = listProducer.list
val onActionDone = MutableEventFlow<ReversibleAction>()
private var commitJob: Job? = null
init {
launchJob(Dispatchers.Default) {
combine(
repository.observeEnabledSources(),
expandedGroups,
searchQuery,
observeTip(),
settings.observeAsFlow(AppSettings.KEY_DISABLE_NSFW) { isNsfwContentDisabled },
) { sources, groups, query, tip, noNsfw ->
buildList(sources, groups, query, tip, noNsfw)
}.collectLatest {
reorderJob?.join()
content.value = it
}
database.invalidationTracker.addObserver(listProducer)
}
}
override fun onCleared() {
super.onCleared()
database.invalidationTracker.removeObserverAsync(listProducer)
}
fun reorderSources(oldPos: Int, newPos: Int) {
val snapshot = content.value.toMutableList()
val prevJob = reorderJob
reorderJob = launchJob(Dispatchers.Default) {
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return@launchJob
}
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return@launchJob
}
snapshot.move(oldPos, newPos)
content.value = snapshot
prevJob?.join()
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
repository.setPositions(newSourcesList)
if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) {
return
}
snapshot.move(oldPos, newPos)
content.value = snapshot
commit(snapshot)
}
fun canReorder(oldPos: Int, newPos: Int): Boolean {
@@ -100,6 +71,32 @@ class SourcesManageViewModel @Inject constructor(
}
}
fun bringToTop(source: MangaSource) {
var oldPos = -1
var newPos = -1
val snapshot = content.value
for ((i, x) in snapshot.withIndex()) {
if (x !is SourceConfigItem.SourceItem) {
continue
}
if (newPos == -1) {
newPos = i
}
if (x.source == source) {
oldPos = i
break
}
}
@Suppress("KotlinConstantConditions")
if (oldPos != -1 && newPos != -1) {
reorderSources(oldPos, newPos)
val revert = ReversibleAction(R.string.moved_to_top) {
reorderSources(newPos, oldPos)
}
onActionDone.call(revert)
}
}
fun disableAll() {
launchJob(Dispatchers.Default) {
repository.disableAllSources()
@@ -107,16 +104,11 @@ class SourcesManageViewModel @Inject constructor(
}
fun expandOrCollapse(headerId: String?) {
val expanded = expandedGroups.value
expandedGroups.value = if (headerId in expanded) {
expanded - headerId
} else {
expanded + headerId
}
listProducer.expandCollapse(headerId)
}
fun performSearch(query: String?) {
searchQuery.value = query?.trim()
listProducer.setQuery(query?.trim().orEmpty())
}
fun onTipClosed(item: SourceConfigItem.Tip) {
@@ -125,113 +117,20 @@ class SourcesManageViewModel @Inject constructor(
}
}
@CheckResult
private fun buildList(
enabledSources: List<MangaSource>,
expanded: Set<String?>,
query: String?,
withTip: Boolean,
isNsfwDisabled: Boolean,
): List<SourceConfigItem> {
val allSources = repository.allMangaSources
val enabledSet = enabledSources.toEnumSet()
if (!query.isNullOrEmpty()) {
return allSources.mapNotNull {
if (!it.title.contains(query, ignoreCase = true)) {
return@mapNotNull null
}
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = it in enabledSet,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}.ifEmpty {
listOf(SourceConfigItem.EmptySearchResult)
}
}
val map = allSources.groupByTo(TreeMap(LocaleKeyComparator())) {
if (it in enabledSet) {
KEY_ENABLED
} else {
it.locale
}
}
map.remove(KEY_ENABLED)
val result = ArrayList<SourceConfigItem>(allSources.size + map.size + 2)
if (enabledSources.isNotEmpty()) {
result += SourceConfigItem.Header(R.string.enabled_sources)
if (withTip) {
result += SourceConfigItem.Tip(TIP_REORDER, R.drawable.ic_tap_reorder, R.string.sources_reorder_tip)
}
enabledSources.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = it.getLocaleTitle(),
isEnabled = true,
isDraggable = true,
isAvailable = false,
)
}
}
if (enabledSources.size != allSources.size) {
result += SourceConfigItem.Header(R.string.available_sources)
val comparator = compareBy<MangaSource, String>(AlphanumComparator()) { it.name }
for ((key, list) in map) {
list.sortWith(comparator)
val isExpanded = key in expanded
result += SourceConfigItem.LocaleGroup(
localeId = key,
title = getLocaleTitle(key),
isExpanded = isExpanded,
)
if (isExpanded) {
list.mapTo(result) {
SourceConfigItem.SourceItem(
source = it,
summary = null,
isEnabled = false,
isDraggable = false,
isAvailable = !isNsfwDisabled || !it.isNsfw(),
)
}
private fun commit(snapshot: List<SourceConfigItem>) {
val prevJob = commitJob
commitJob = launchJob {
prevJob?.cancelAndJoin()
delay(500)
val newSourcesList = snapshot.mapNotNull { x ->
if (x is SourceConfigItem.SourceItem && x.isDraggable) {
x.source
} else {
null
}
}
}
return result
}
private fun getLocaleTitle(localeKey: String?): String? {
val locale = Locale(localeKey ?: return null)
return locale.getDisplayLanguage(locale).toTitleCase(locale)
}
private fun observeTip() = settings.observeAsFlow(AppSettings.KEY_TIPS_CLOSED) {
isTipEnabled(TIP_REORDER)
}
private fun MangaSource.isNsfw() = contentType == ContentType.HENTAI
private class LocaleKeyComparator : Comparator<String?> {
private val deviceLocales = LocaleListCompat.getAdjustedDefault()
.map { it.language }
override fun compare(a: String?, b: String?): Int {
when {
a == b -> return 0
a == null -> return 1
b == null -> return -1
}
val ai = deviceLocales.indexOf(a!!)
val bi = deviceLocales.indexOf(b!!)
return when {
ai < 0 && bi < 0 -> a.compareTo(b)
ai < 0 -> 1
bi < 0 -> -1
else -> ai.compareTo(bi)
}
repository.setPositions(newSourcesList)
yield()
}
}
}

View File

@@ -7,6 +7,7 @@ import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.isGone
@@ -34,7 +35,13 @@ import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem
fun sourceConfigHeaderDelegate() =
adapterDelegateViewBinding<SourceConfigItem.Header, SourceConfigItem, ItemFilterHeaderBinding>(
{ layoutInflater, parent -> ItemFilterHeaderBinding.inflate(layoutInflater, parent, false) },
{ layoutInflater, parent ->
ItemFilterHeaderBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
bind {
@@ -44,104 +51,121 @@ fun sourceConfigHeaderDelegate() =
fun sourceConfigGroupDelegate(
listener: SourceConfigListener,
) = adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.LocaleGroup, SourceConfigItem, ItemExpandableBinding>(
{ layoutInflater, parent -> ItemExpandableBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
binding.root.setOnClickListener {
listener.onHeaderClick(item)
}
bind {
binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded
bind {
binding.root.text = item.title ?: getString(R.string.various_languages)
binding.root.isChecked = item.isExpanded
}
}
}
fun sourceConfigItemCheckableDelegate(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent -> ItemSourceConfigCheckableBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigCheckableBinding>(
{ layoutInflater, parent ->
ItemSourceConfigCheckableBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemEnabledChanged(item, isChecked)
}
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon =
FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
} else {
item.source.title
}
binding.switchToggle.isChecked = item.isEnabled
binding.switchToggle.isEnabled = item.isAvailable
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
}
}
fun sourceConfigItemDelegate2(
listener: SourceConfigListener,
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent -> ItemSourceConfigBinding.inflate(layoutInflater, parent, false) },
) {
) =
adapterDelegateViewBinding<SourceConfigItem.SourceItem, SourceConfigItem, ItemSourceConfigBinding>(
{ layoutInflater, parent ->
ItemSourceConfigBinding.inflate(
layoutInflater,
parent,
false
)
},
) {
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_config -> listener.onItemSettingsClick(item)
}
}
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewConfig.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
val eventListener = View.OnClickListener { v ->
when (v.id) {
R.id.imageView_add -> listener.onItemEnabledChanged(item, true)
R.id.imageView_remove -> listener.onItemEnabledChanged(item, false)
R.id.imageView_menu -> showSourceMenu(v, item, listener)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewConfig.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
binding.imageViewRemove.setOnClickListener(eventListener)
binding.imageViewAdd.setOnClickListener(eventListener)
binding.imageViewMenu.setOnClickListener(eventListener)
bind {
binding.textViewTitle.text = if (item.isNsfw) {
buildSpannedString {
append(item.source.title)
append(' ')
appendNsfwLabel(context)
}
} else {
item.source.title
}
binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable
binding.imageViewRemove.isVisible = item.isEnabled
binding.imageViewMenu.isVisible = item.isEnabled
binding.textViewDescription.textAndVisible = item.summary
val fallbackIcon =
FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name)
binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run {
crossfade(context)
error(fallbackIcon)
placeholder(fallbackIcon)
fallback(fallbackIcon)
source(item.source)
enqueueWith(coil)
}
}
}
}
fun sourceConfigTipDelegate(
listener: OnTipCloseListener<SourceConfigItem.Tip>
listener: OnTipCloseListener<SourceConfigItem.Tip>,
) = adapterDelegateViewBinding<SourceConfigItem.Tip, SourceConfigItem, ItemTipBinding>(
{ layoutInflater, parent -> ItemTipBinding.inflate(layoutInflater, parent, false) },
) {
@@ -156,14 +180,37 @@ fun sourceConfigTipDelegate(
}
}
fun sourceConfigEmptySearchDelegate() = adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty,
) { }
fun sourceConfigEmptySearchDelegate() =
adapterDelegate<SourceConfigItem.EmptySearchResult, SourceConfigItem>(
R.layout.item_sources_empty,
) { }
fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan(context.getThemeColor(com.google.android.material.R.attr.colorError, Color.RED)),
ForegroundColorSpan(
context.getThemeColor(
com.google.android.material.R.attr.colorError,
Color.RED
)
),
RelativeSizeSpan(0.74f),
SuperscriptSpan(),
) {
append(context.getString(R.string.nsfw))
}
private fun showSourceMenu(
anchor: View,
item: SourceConfigItem.SourceItem,
listener: SourceConfigListener,
) {
val menu = PopupMenu(anchor.context, anchor)
menu.inflate(R.menu.popup_source_config)
menu.setOnMenuItemClickListener {
when (it.itemId) {
R.id.action_settings -> listener.onItemSettingsClick(item)
R.id.action_lift -> listener.onItemLiftClick(item)
}
true
}
menu.show()
}

View File

@@ -7,6 +7,8 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
fun onItemSettingsClick(item: SourceConfigItem.SourceItem)
fun onItemLiftClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup)

View File

@@ -72,11 +72,7 @@ sealed interface SourceConfigItem : ListModel {
}
}
object EmptySearchResult : SourceConfigItem {
override fun equals(other: Any?): Boolean {
return other === EmptySearchResult
}
data object EmptySearchResult : SourceConfigItem {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is EmptySearchResult

View File

@@ -27,7 +27,11 @@ class SuggestionsFragment : MangaListFragment() {
override fun onScrolledToEnd() = Unit
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
override fun onCreateActionMode(
controller: ListSelectionController,
mode: ActionMode,
menu: Menu,
): Boolean {
mode.menuInflater.inflate(R.menu.mode_remote, menu)
return super.onCreateActionMode(controller, mode, menu)
}
@@ -38,6 +42,12 @@ class SuggestionsFragment : MangaListFragment() {
menuInflater.inflate(R.menu.opt_suggestions, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_settings_suggestions)?.isVisible =
menu.findItem(R.id.action_settings) == null
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_update -> {
viewModel.updateSuggestions()
@@ -49,7 +59,7 @@ class SuggestionsFragment : MangaListFragment() {
true
}
R.id.action_settings -> {
R.id.action_settings_suggestions -> {
startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext()))
true
}
@@ -60,6 +70,12 @@ class SuggestionsFragment : MangaListFragment() {
companion object {
@Deprecated(
"", ReplaceWith(
"SuggestionsFragment()",
"org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment"
)
)
fun newInstance() = SuggestionsFragment()
}
}

View File

@@ -34,6 +34,7 @@ import coil.request.ImageRequest
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
@@ -84,6 +85,8 @@ class TrackWorker @AssistedInject constructor(
logger.log("doWork(): attempt $runAttemptCount")
return try {
doWorkImpl()
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
logger.log("fatal", e)
Result.failure()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/bookmark.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M17,3H7A2,2 0 0,0 5,5V21L12,18L19,21V5C19,3.89 18.1,3 17,3Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_bookmark"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_bookmark_checked"
android:state_checked="true" />
</selector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/sd.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M18,8H16V4H18M15,8H13V4H15M12,8H10V4H12M18,2H10L4,8V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_storage"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_storage_checked"
android:state_checked="true" />
</selector>

View File

@@ -9,4 +9,4 @@
<path
android:fillColor="#000"
android:pathData="M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7M9 21v-1h6v1a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1m3-17a5 5 0 0 0-5 5c0 2.05 1.23 3.81 3 4.58V16h4v-2.42c1.77-0.77 3-2.53 3-4.58a5 5 0 0 0-5-5z" />
</vector>
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!-- drawable/lightbulb.xml -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,2A7,7 0 0,0 5,9C5,11.38 6.19,13.47 8,14.74V17A1,1 0 0,0 9,18H15A1,1 0 0,0 16,17V14.74C17.81,13.47 19,11.38 19,9A7,7 0 0,0 12,2M9,21A1,1 0 0,0 10,22H14A1,1 0 0,0 15,21V20H9V21Z" />
</vector>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/normal"
android:drawable="@drawable/ic_suggestion"
android:state_checked="false" />
<item
android:id="@+id/checked"
android:drawable="@drawable/ic_suggestion_checked"
android:state_checked="true" />
</selector>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawablePadding="?listPreferredItemPaddingStart"
android:ellipsize="end"
android:gravity="center_vertical"
android:minHeight="?android:listPreferredItemHeightSmall"
android:orientation="horizontal"
android:paddingVertical="@dimen/margin_small"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:drawableStartCompat="@drawable/ic_feed"
tools:text="@string/feed" />

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:windowBackground"
android:baselineAligned="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingVertical="@dimen/margin_small"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd">
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:drawablePadding="?listPreferredItemPaddingStart"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:drawableStart="@drawable/ic_explore_selector"
tools:text="@string/explore" />
<ImageView
android:id="@+id/imageView_remove"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/remove"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_delete" />
<ImageView
android:id="@+id/imageView_reorder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/reorder"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_reorder_handle" />
</LinearLayout>

View File

@@ -52,14 +52,15 @@
</LinearLayout>
<ImageView
android:id="@+id/imageView_config"
android:id="@+id/imageView_menu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/settings"
android:contentDescription="@string/more"
android:padding="@dimen/margin_small"
android:scaleType="center"
android:src="@drawable/ic_settings" />
android:src="@drawable/abc_ic_menu_overflow_material"
app:tint="?colorControlNormal" />
<ImageView
android:id="@+id/imageView_add"

View File

@@ -14,7 +14,8 @@
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:minHeight="240dp">
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
android:id="@+id/recyclerView"
@@ -29,6 +30,19 @@
tools:listitem="@layout/item_page_thumb"
tools:targetApi="m" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:showAnimationBehavior="inward"
app:trackCornerRadius="0dp"
app:trackThickness="2dp"
tools:visibility="visible" />
</FrameLayout>
</LinearLayout>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_history"
android:icon="@drawable/ic_history_selector"
android:title="@string/history" />
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_favourites_selector"
android:title="@string/favourites" />
<item
android:id="@+id/nav_explore"
android:icon="@drawable/ic_explore_selector"
android:title="@string/explore" />
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed_selector"
android:title="@string/feed" />
</menu>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_favourites"
android:icon="@drawable/ic_favourites_selector"
android:title="@string/favourites" />
<item
android:id="@+id/nav_history"
android:icon="@drawable/ic_history_selector"
android:title="@string/history" />
<item
android:id="@+id/nav_explore"
android:icon="@drawable/ic_explore_selector"
android:title="@string/explore" />
<item
android:id="@+id/nav_feed"
android:icon="@drawable/ic_feed_selector"
android:title="@string/feed" />
</menu>

View File

@@ -10,9 +10,9 @@
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:orderInCategory="100"
android:title="@string/settings"
android:id="@+id/action_directories"
android:orderInCategory="96"
android:title="@string/directories"
app:showAsAction="never" />
</menu>

View File

@@ -16,11 +16,6 @@
android:title="@string/disable_nsfw"
app:showAsAction="never" />
<item
android:id="@+id/action_locales"
android:title="@string/languages"
app:showAsAction="never" />
<item
android:id="@+id/action_disable_all"
android:title="@string/disable_all"

View File

@@ -10,9 +10,9 @@
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:id="@+id/action_settings_suggestions"
android:orderInCategory="90"
android:title="@string/settings"
app:showAsAction="never" />
</menu>
</menu>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_lift"
android:title="@string/to_top" />
<item
android:id="@+id/action_settings"
android:title="@string/settings" />
</menu>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="detailed_list">تفاصيل القائمة</string>
<string name="error_occurred">حدث خطأ</string>
<string name="details">التفاصيل</string>
@@ -19,7 +19,7 @@
<string name="history">السجل</string>
<string name="list">قائمة</string>
<string name="clear_history">محو سجل</string>
<string name="add_to_favourites">ضع هذا في المفضلة</string>
<string name="add_to_favourites">أضف للمفضلة</string>
<string name="add">أضف</string>
<string name="save">حفظ</string>
<string name="history_is_empty">لا سجل بعد</string>
@@ -48,11 +48,11 @@
<string name="clear">أزل</string>
<string name="remove">ازالة</string>
<string name="popular">شائع</string>
<string name="add_new_category">قائمة جديدة</string>
<string name="add_new_category">أضف فئة جديدة</string>
<string name="download_complete">تم التنزيل</string>
<string name="text_clear_history_prompt">هل تريد محو سجل القراءة بالكامل بشكل دائم؟</string>
<string name="save_page">احفظ الصفحة</string>
<string name="page_saved">حفظت</string>
<string name="page_saved">تم الحفظ</string>
<string name="standard">اساسي</string>
<string name="no_description">لا يوجد وصف</string>
<string name="clear_pages_cache">مسح ذاكرة التخزين المؤقت للصفحة</string>
@@ -61,7 +61,7 @@
<string name="search_on_s">بحث على %s</string>
<string name="delete_manga">حذف المانغا</string>
<string name="text_delete_local_manga">حذف \"%s\" من الجهاز نهائيا؟</string>
<string name="reader_settings">إعدادات القراءة</string>
<string name="reader_settings">إعدادات القارىء</string>
<string name="switch_pages">تغییر صفحات</string>
<string name="delete">حذف</string>
<string name="share_image">شارك الصورة</string>
@@ -71,8 +71,110 @@
<string name="grid_size">حجم الشبكة</string>
<string name="volume_buttons">أزرار الصوت</string>
<string name="taps_on_edges">النقر على حواف الشاشة</string>
<string name="_continue">يكمل</string>
<string name="error">خطاء</string>
<string name="_continue">أكمل</string>
<string name="error">خطأ</string>
<string name="clear_search_history">مسح تاريخ البحث</string>
<string name="disable_nsfw">تعطيل NSFW</string>
<string name="updates">التحديثات</string>
<string name="sync_host_description">يمكنك استخدام سيرفر التزامن ذاتياً أو السيرفر الافتراضي. لا تغير هذا إن لم تكن متأكداً مما تفعله.</string>
<string name="text_clear_cookies_prompt">سيتم تسجيل خروجك من جميع المصادر</string>
<string name="clear_cookies">مسح ملفات تعريف الارتباط</string>
<string name="favourites_categories">الفئات المفضلة</string>
<string name="enabled_sources">المصادر المستخدمة</string>
<string name="gestures_only">الإيماءات فقط</string>
<string name="clear_thumbs_cache">مسح ذاكرة التخزين المؤقت للصور المصغرة</string>
<string name="rotate_screen">تدوير الشاشة</string>
<string name="text_clear_updates_feed_prompt">هل تريد مسح سجل التحديث بشكل دائم؟</string>
<string name="suggestions_enable">تفعيل الاقتراحات</string>
<string name="clear_feed">مسح الخلاصة</string>
<string name="welcome">مرحبا</string>
<string name="about_app_translation_summary">ترجمة هذا التطبيق</string>
<string name="vibration">اهتزاز</string>
<string name="no_update_available">لا يوجد تحديثات</string>
<string name="remove_category">حذف الفئة</string>
<string name="internal_storage">التخزين الداخلي</string>
<string name="read_later">اقرأ لاحقا</string>
<string name="backup_saved">تم حفظ النسخة الاحتياطية</string>
<string name="create_backup">إنشاء نسخة احتياطية</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">تمكين %1$d من %2$d</string>
<string name="tap_to_try_again">انقر لإعادة المحاولة</string>
<string name="ignore_ssl_errors">تجاهل أخطاء SSL</string>
<string name="auth_required">سجل الدخول لمشاهدة المحتوى</string>
<string name="next">التالي</string>
<string name="restore_backup">استعادة من نسخة احتياطية</string>
<string name="password_length_hint">كلمة السر يجب أن تكون 4 أحرف أو أكثر</string>
<string name="server_address">عنوان السيرفر</string>
<string name="text_feed_holder">فصول جديدة من ما تقرأه تظهر هنا</string>
<string name="text_suggestion_holder">ابدأ بقراءة المانجا وستحصل على اقتراحات مخصصة</string>
<string name="find_similar">ابحث عن متشابه</string>
<string name="data_restored">تم الاستعادة</string>
<string name="protect_application_subtitle">أدخل كلمة السر لبدء التطبيق</string>
<string name="suggestions">الاقتراحات</string>
<string name="enabled">مفعل</string>
<string name="text_clear_search_history_prompt">هل تريد إزالة استعلامات البحث الأخيرة نهائيًا؟</string>
<string name="updates_feed_cleared">تم المسح</string>
<string name="update">تحديث</string>
<string name="feed_will_update_soon">سيبدأ تحديث الخلاصة قريبًا</string>
<string name="app_update_available">تتوفر نسخة جديدة من التطبيق</string>
<string name="new_version_s">نسخة جديدة: %s</string>
<string name="sync_settings">إعدادات التزامن</string>
<string name="create_category">أضف فئة جديدة</string>
<string name="notification_sound">صوت الإشعار</string>
<string name="backup_restore">النسخ الاحتياطي و الاستعادة</string>
<string name="show_pages_numbers">إظهار أرقام الصفحات</string>
<string name="search_history_cleared">تم المسح</string>
<string name="_s_deleted_from_local_storage">تم ازالة \"%s\" من التخزين المحلي</string>
<string name="saved_manga">المانجا المحفوظة</string>
<string name="open_in_browser">الفتح في المتصفح</string>
<string name="about_app_translation">ترجمة</string>
<string name="notifications">الإشعارات</string>
<string name="reverse">العكس</string>
<string name="track_sources">البحث عن تحديثات</string>
<string name="wrong_password">كلمة سر خاطئة</string>
<string name="group">المجموعة</string>
<string name="just_now">الآن</string>
<string name="download">تنزيل</string>
<string name="chapter_is_missing">الفصل مفقود</string>
<string name="size_s">الحجم: %s</string>
<string name="about">حول</string>
<string name="check_for_new_chapters">التحقق من الفصول الجديدة</string>
<string name="captcha_solve">حل</string>
<string name="data_restored_with_errors">أستعيدت البيانات، لكن هناك أخطاء</string>
<string name="new_chapters">فصول جديدة</string>
<string name="exit_confirmation">تأكيد الخروج</string>
<string name="protect_application">حماية التطبيق</string>
<string name="passwords_mismatch">كلمة السر غير مطابقة</string>
<string name="yesterday">أمس</string>
<string name="check_for_updates">تحقق من وجود تحديثات</string>
<string name="protect_application_summary">اطلب كلمة السر عند تشغيل كوتاتسو</string>
<string name="right_to_left">من اليمين الى اليسار</string>
<string name="reader_mode_hint">سيتم تذكر الاعدادات المختارة لهذه المانجا</string>
<string name="default_s">الافتراضي: %s</string>
<string name="confirm">تأكد</string>
<string name="clear_updates_feed">مسح موجز التحديثات</string>
<string name="disabled">معطل</string>
<string name="long_ago">منذ فترة</string>
<string name="notifications_settings">إعدادات الإشعارات</string>
<string name="save_manga">حفظ</string>
<string name="large_manga_save_confirm">تحتوي هذه المانجا على s%. حفظ الكل؟</string>
<string name="read_more">اقرأ المزيد</string>
<string name="search_results">نتائج البحث</string>
<string name="file_not_found">الملف غير موجود</string>
<string name="app_version">نسخة %s</string>
<string name="cookies_cleared">تمت إزالة جميع ملفات تعريف الارتباط</string>
<string name="state_finished">انتهت</string>
<string name="state_ongoing">مستمرة</string>
<string name="suggestions_summary">أقترح المانجا بناء على تفضيلاتك</string>
<string name="preparing_">جارٍ التحضير…</string>
<string name="exit_confirmation_summary">اضغط مرتين للخروج من التطبيق</string>
<string name="enter_password">ادخل كلمة السر</string>
<string name="repeat_password">كرر كلمة السر</string>
<string name="data_restored_success">استعيدت جميع البيانات</string>
<string name="backup_information">يمكنك إنشاء نسخة احتياطية من السجل الخاص بك والمفضلة واستعادتها</string>
<string name="available_sources">المصادر المتاحة</string>
<string name="external_storage">التخزين الخارجي</string>
<string name="silent">صامت</string>
<string name="today">اليوم</string>
<string name="system_default">الافتراضي</string>
<string name="sign_in">تسجبل الدخول</string>
</resources>

View File

@@ -472,4 +472,11 @@
<string name="advanced">Пашыраныя</string>
<string name="default_section">Раздзел па змаўчанні</string>
<string name="manga_list">Спіс мангі</string>
<string name="error_corrupted_file">Вяртаюцца няправільныя дадзеныя ці файл пашкоджаны</string>
<string name="on_device">На прыладзе</string>
<string name="moved_to_top">Перанесены ўверх</string>
<string name="items_limit_exceeded">Больш нельга дадаваць элементы</string>
<string name="directories">Каталогі</string>
<string name="main_screen_sections">Раздзелы галоўнага экрана</string>
<string name="to_top">Уверх</string>
</resources>

View File

@@ -472,4 +472,11 @@
<string name="advanced">Avanzado</string>
<string name="default_section">Sección predeterminada</string>
<string name="manga_list">Lista de mangas</string>
<string name="error_corrupted_file">Los datos que devuelve el archivo no son válidos o el archivo está dañado</string>
<string name="on_device">En el dispositivo</string>
<string name="moved_to_top">Movido hacia arriba</string>
<string name="items_limit_exceeded">No se pueden añadir más elementos</string>
<string name="directories">Directorios</string>
<string name="main_screen_sections">Secciones de la pantalla principal</string>
<string name="to_top">Hasta arriba</string>
</resources>

View File

@@ -467,4 +467,10 @@
<string name="unknown">Di-alam</string>
<string name="in_progress">Isinasagawa</string>
<string name="disable_nsfw">I-disable ang NSFW</string>
<string name="error_corrupted_file">Ang hindi wastong data ay ibinalik nito o ang file ay nasira</string>
<string name="related_manga_summary">Magpakita ng listahan ng mga kaugnay na manga. Sa ilang mga kaso, ito ay maaaring may mali o nawawala</string>
<string name="advanced">Advanced</string>
<string name="too_many_requests_message">Masyadong maraming request. Subukang ulit mamaya</string>
<string name="default_section">Default na seksyon</string>
<string name="manga_list">Listahan ng Manga</string>
</resources>

View File

@@ -472,4 +472,9 @@
<string name="advanced">Lanjutan</string>
<string name="default_section">Bagian default</string>
<string name="manga_list">Daftar manga</string>
<string name="error_corrupted_file">Data yang dikembalikan tidak valid atau file rusak</string>
<string name="on_device">Dari perangkat</string>
<string name="items_limit_exceeded">Tidak ada lagi item yang bisa ditambahkan</string>
<string name="directories">Direktori</string>
<string name="main_screen_sections">Bagian layar utama</string>
</resources>

View File

@@ -273,7 +273,7 @@
<string name="show_reading_indicators_summary">履歴とお気に入りに既読率を表示する</string>
<string name="clear_cookies_summary">いくつかの問題の場合に助けることができる。すべての認証が無効になります</string>
<string name="show_reading_indicators">読書の進行状況インジケーターを表示</string>
<string name="exclude_nsfw_from_history_summary">NSFWとマークされたマンガは履歴に追加されず、進行状況も保存されない</string>
<string name="exclude_nsfw_from_history_summary">NSFWとしてマークされたマンガは履歴に追加されず、進行状況は保存されません</string>
<string name="show_all">すべて表示</string>
<string name="invalid_domain_message">無効なドメイン</string>
<string name="select_range">範囲を選択</string>
@@ -350,13 +350,13 @@
<string name="nothing_here">ここには何もありません</string>
<string name="scrobbling_empty_hint">読書の進捗状況を確認するには、マンガの詳細画面で「メニュー」→「追跡」を選択します。</string>
<string name="services">サービス</string>
<string name="enable_logging_summary">デバッグ目的でいくつかのアクションを記録する</string>
<string name="enable_logging_summary">デバッグ目的でいくつかのアクションを記録します。 何をしているのか分からない場合はオンにしないでください</string>
<string name="theme_name_miku">ミク</string>
<string name="user_agent">ユーザー エージェント ヘッダー</string>
<string name="prefetch_content">コンテンツのプリロード</string>
<string name="share_logs">ログを共有</string>
<string name="allow_unstable_updates">不安定な更新を許可</string>
<string name="allow_unstable_updates_summary">アプリのベータ版へのアップデートを提案する</string>
<string name="allow_unstable_updates_summary">不安定なビルドに関する通知を受け取る</string>
<string name="download_started">ダウンロードが開始されました</string>
<string name="language">言語</string>
<string name="source_disabled">ソースが無効になっています</string>
@@ -401,7 +401,7 @@
<string name="speed">速度</string>
<string name="ignore_ssl_errors">SSLエラーを無視する</string>
<string name="mirror_switching">ミラーを自動的に選択する</string>
<string name="mirror_switching_summary">ミラーがある場合、エラー時にリモートソースのドメインを自動で切り替える</string>
<string name="mirror_switching_summary">ミラーが利用可能な場合、エラー時にマンガ ソースのドメインを自動的に切り替える</string>
<string name="resume">履歴書</string>
<string name="paused">一時停止</string>
<string name="cancel_all">全てキャンセル</string>
@@ -466,4 +466,17 @@
<string name="languages">言語</string>
<string name="unknown">不明</string>
<string name="in_progress">進行状況</string>
<string name="error_corrupted_file">無効なデータが返されたか、ファイルが破損しています</string>
<string name="related_manga_summary">関連漫画のリストを表示します。場合によっては不正確または欠落している可能性があります</string>
<string name="advanced">高度</string>
<string name="too_many_requests_message">リクエストが多すぎます。 後でもう一度お試しください</string>
<string name="default_section">デフォルトセクション</string>
<string name="manga_list">漫画一覧</string>
<string name="disable_nsfw">NSFWを非表示にする</string>
<string name="on_device">デバイス上</string>
<string name="items_limit_exceeded">これ以上アイテムを追加出来ません</string>
<string name="directories">ディレクトリ</string>
<string name="main_screen_sections">メイン画面のセクション</string>
<string name="moved_to_top">上部に移動しました</string>
<string name="to_top">最上部へ移動</string>
</resources>

View File

@@ -472,4 +472,11 @@
<string name="advanced">Расширенные</string>
<string name="default_section">Раздел по умолчанию</string>
<string name="manga_list">Список манги</string>
<string name="error_corrupted_file">Возвращаются неверные данные или файл поврежден</string>
<string name="on_device">На устройстве</string>
<string name="moved_to_top">Перемещено наверх</string>
<string name="items_limit_exceeded">Больше нельзя добавлять элементы</string>
<string name="directories">Каталоги</string>
<string name="main_screen_sections">Разделы главного экрана</string>
<string name="to_top">Наверх</string>
</resources>

View File

@@ -269,4 +269,36 @@
<string name="status_on_hold">พักไว้</string>
<string name="disable_all">ปิดการใช้งานทั้งหมด</string>
<string name="report">รีพอร์ต</string>
<string name="text_clear_cookies_prompt">คุณจะออกจากระบบจากทุกแหล่ง</string>
<string name="clear_all_history">ลบประวัติทั้งหมด</string>
<string name="data_deletion">การลบข้อมูล</string>
<string name="show_reading_indicators">แสดงตัวบอกความคืบหน้าในการอ่าน</string>
<string name="show_notification_new_chapters_off">คุณจะไม่ได้รับการแจ้งเตือน แต่บทใหม่จะถูกเน้นในรายการ</string>
<string name="text_clear_search_history_prompt">ลบคำค้นหาล่าสุดทั้งหมดอย่างถาวรไหม\?</string>
<string name="filter_load_error">ไม่สามารถโหลดรายการประเภทได้</string>
<string name="detect_reader_mode_summary">ตรวจสอบอัตโนมัติว่ามังงะเป็นเว็บตูนหรือไม่</string>
<string name="appwidget_recent_description">มังงะที่คุณอ่านล่าสุด</string>
<string name="appearance">รูปร่าง</string>
<string name="bookmark_remove">ลบบุ๊คมาร์ค</string>
<string name="auth_not_supported_by">%s ไม่รองรับการเข้าสู่ระบบ</string>
<string name="last_2_hours">2 ชั่วโมงที่ผ่านมา</string>
<string name="edit_category">แก้ไขหมวดหมู่</string>
<string name="bookmark_removed">บุ๊คมาร์คถูกลบแล้ว</string>
<string name="suggestions_excluded_genres_summary">ระบุประเภทที่คุณไม่ต้องการเห็นในคำแนะนำ</string>
<string name="dns_over_https">DNS บน HTTPS</string>
<string name="appwidget_shelf_description">มังงะจากรายการโปรดของคุณ</string>
<string name="bookmark_add">เพิ่มบุ๊คมาร์ค</string>
<string name="logged_in_as">เข้าสู่ระบบด้วย %s</string>
<string name="history_cleared">ลบประวัติแล้ว</string>
<string name="bookmark_added">บุ๊คมาร์คได้ถูกเพิ่มแล้ว</string>
<string name="suggestions_excluded_genres">ยกเว้นประเภท</string>
<string name="exclude_nsfw_from_history_summary">มังงะที่เป็น NSFW จะไม่ถูกเพิ่มเข้าไปในประวัติ และความคืบหน้าของคุณจะไม่ถูกบันทึก</string>
<string name="show_reading_indicators_summary">แสดงเปอร์เซ็นต์การอ่านในประวัติและรายการโปรด</string>
<string name="manage">จัดการ</string>
<string name="logout">ออกจากระบบ</string>
<string name="no_bookmarks_yet">ยังไม่มีบุ๊คมาร์ก</string>
<string name="bookmarks">บุ๊คมาร์ค</string>
<string name="show_all">แสดงทั้งหมด</string>
<string name="empty_favourite_categories">ไม่มีหมวดหมู่ที่ชื่นชอบ</string>
<string name="invalid_domain_message">โดเมนไม่ถูกต้อง</string>
</resources>

View File

@@ -472,4 +472,11 @@
<string name="advanced">Розширені</string>
<string name="default_section">Розділ за замовчуванням</string>
<string name="manga_list">Список манґи</string>
<string name="error_corrupted_file">Повертаються неправильні дані або файл пошкоджено</string>
<string name="on_device">На пристрої</string>
<string name="moved_to_top">Перенесено вгору</string>
<string name="items_limit_exceeded">Більше не можна додавати елементи</string>
<string name="directories">Каталоги</string>
<string name="main_screen_sections">Розділи головного екрана</string>
<string name="to_top">Вгору</string>
</resources>

View File

@@ -1,21 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="items">
<item quantity="other">%1$d 个项目</item>
</plurals>
<plurals name="new_chapters">
<item quantity="other">%1$d 个新章节</item>
</plurals>
<plurals name="chapters">
<item quantity="other">%1$d 个章节</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="other">%1$d 分钟前</item>
</plurals>
<plurals name="hours_ago">
<item quantity="other">%1$d 小时前</item>
</plurals>
<plurals name="days_ago">
<item quantity="other">%1$d 天前</item>
</plurals>
</resources>
<plurals name="items">
<item quantity="other">%1$d 个项目</item>
</plurals>
<plurals name="new_chapters">
<item quantity="other">%1$d 个新章节</item>
</plurals>
<plurals name="chapters">
<item quantity="other">%1$d 个章节</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="other">%1$d 分钟前</item>
</plurals>
<plurals name="hours_ago">
<item quantity="other">%1$d 小时前</item>
</plurals>
<plurals name="days_ago">
<item quantity="other">%1$d 天前</item>
</plurals>
<plurals name="months_ago">
<item quantity="other">%1$d 个月前</item>
</plurals>
</resources>

View File

@@ -2,31 +2,31 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="settings">设置</string>
<string name="local_storage">本地存储</string>
<string name="favourites">喜欢</string>
<string name="favourites">收藏</string>
<string name="history">历史</string>
<string name="error_occurred">发生了一个错误</string>
<string name="network_error">网络错误</string>
<string name="chapters">章节</string>
<string name="list">列表</string>
<string name="data_restored_with_errors">数据被恢复了,但有错误</string>
<string name="list">紧凑</string>
<string name="data_restored_with_errors">数据已恢复,但有一些错误</string>
<string name="processing_">正在处理…</string>
<string name="newest">最新</string>
<string name="by_rating">评分</string>
<string name="cookies_cleared">已删除所有 cookie</string>
<string name="data_restored_success">所有数据都被恢复了</string>
<string name="cookies_cleared">已清除所有 cookie</string>
<string name="data_restored_success">数据已全部恢复</string>
<string name="silent">无声</string>
<string name="preparing_">准备…</string>
<string name="file_not_found">未找到文件</string>
<string name="yesterday">昨日</string>
<string name="backup_information">你可以创建你的历史和收藏的备份并恢复它</string>
<string name="just_now">现在</string>
<string name="just_now">刚刚</string>
<string name="long_ago">很久以前</string>
<string name="group">组</string>
<string name="tap_to_try_again">轻点以重试</string>
<string name="reader_mode_hint">所选择的配置将因这部漫画而被记住</string>
<string name="group">分组</string>
<string name="tap_to_try_again">轻击重试</string>
<string name="reader_mode_hint">所选配置将被这部漫画记住</string>
<string name="captcha_required">需要验证码</string>
<string name="captcha_solve">解决</string>
<string name="today">今天</string>
<string name="today">今日</string>
<string name="clear_cookies">清除cookies</string>
<string name="new_sources_text">有新的漫画源可用</string>
<string name="suggestions_summary">根据你的喜好推荐漫画</string>
@@ -36,24 +36,24 @@
<string name="nsfw">18+</string>
<string name="various_languages">各种语言</string>
<string name="search_chapters">查找章节</string>
<string name="suggestions_excluded_genres">排除流派</string>
<string name="suggestions_updating">建议更新</string>
<string name="check_new_chapters_title">检查新的章节并通知有关情况</string>
<string name="suggestions_excluded_genres">排除分类</string>
<string name="suggestions_updating">漫画推荐更新</string>
<string name="check_new_chapters_title">检查新章节并通知检查结果</string>
<string name="details">详细内容</string>
<string name="detailed_list">详细列表</string>
<string name="detailed_list">详细</string>
<string name="grid">网格</string>
<string name="list_mode">列表模式</string>
<string name="remote_sources">漫画源</string>
<string name="loading_">加载中…</string>
<string name="computing_">计算中…</string>
<string name="chapter_d_of_d">%1$d/%2$d章节</string>
<string name="chapter_d_of_d">章节 %1$d/%2$d</string>
<string name="close">关闭</string>
<string name="try_again">再试一次</string>
<string name="clear_history">清除历史</string>
<string name="nothing_found">没有发现</string>
<string name="nothing_found">未找到</string>
<string name="history_is_empty">尚无历史</string>
<string name="read">阅读</string>
<string name="you_have_not_favourites_yet">尚无收藏夹</string>
<string name="you_have_not_favourites_yet">尚无收藏</string>
<string name="add_to_favourites">收藏此漫画</string>
<string name="add_new_category">新分类</string>
<string name="add">添加</string>
@@ -68,14 +68,14 @@
<string name="downloads">下载</string>
<string name="by_name">名称</string>
<string name="popular">热门</string>
<string name="updated">更新</string>
<string name="sort_order">排序顺序</string>
<string name="filter">过滤器</string>
<string name="updated">已更新</string>
<string name="sort_order">排序</string>
<string name="filter">筛选</string>
<string name="theme">主题</string>
<string name="dark">深色</string>
<string name="light">浅色</string>
<string name="automatic">跟随系统</string>
<string name="pages">页数</string>
<string name="pages">页面</string>
<string name="clear">清除</string>
<string name="text_clear_history_prompt">永久清除所有阅读历史\?</string>
<string name="remove">删除</string>
@@ -86,8 +86,8 @@
<string name="_import">导入</string>
<string name="delete">删除</string>
<string name="operation_not_supported">不支持此操作</string>
<string name="text_file_not_supported">选择 ZIP 或 CBZ 文件.</string>
<string name="no_description">无描述</string>
<string name="text_file_not_supported">选择 ZIP 或 CBZ 文件。</string>
<string name="no_description">无简介</string>
<string name="clear_pages_cache">清除页面缓存</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string>
<string name="standard">标准</string>
@@ -98,21 +98,21 @@
<string name="delete_manga">删除漫画</string>
<string name="text_delete_local_manga">从设备中永久删除\"%s\"\?</string>
<string name="reader_settings">阅读器设置</string>
<string name="switch_pages">切换页面</string>
<string name="switch_pages">翻页方式</string>
<string name="volume_buttons">音量按钮</string>
<string name="_continue">继续</string>
<string name="taps_on_edges">边缘点击</string>
<string name="error">错误</string>
<string name="clear_thumbs_cache">清除缩略图缓存</string>
<string name="clear_search_history">清除搜索历史</string>
<string name="search_history_cleared">清除</string>
<string name="search_history_cleared">已清除</string>
<string name="gestures_only">仅限手势</string>
<string name="internal_storage">内部存储</string>
<string name="external_storage">外部存储</string>
<string name="domain">范围</string>
<string name="app_update_available">新版本应用程序已经推出</string>
<string name="open_in_browser">在网络浏览器中打开</string>
<string name="large_manga_save_confirm">这部漫画有%s.全部保存\?</string>
<string name="large_manga_save_confirm">这部漫画有 %s 。全部保存?</string>
<string name="save_manga">保存</string>
<string name="notifications">通知</string>
<string name="new_chapters">新章节</string>
@@ -121,34 +121,34 @@
<string name="notification_sound">通知声音</string>
<string name="light_indicator">LED指示器</string>
<string name="vibration">振动</string>
<string name="favourites_categories">收藏夹分类</string>
<string name="favourites_categories">收藏分类</string>
<string name="remove_category">删除</string>
<string name="text_empty_holder_primary">这里有点空…</string>
<string name="text_search_holder_secondary">尝试重新表述查询。</string>
<string name="text_history_holder_primary">你所读的内容将在这里显示</string>
<string name="text_history_holder_secondary">在 «探索»部分找到要读的内容</string>
<string name="text_history_holder_primary">你看过的内容将在这里显示</string>
<string name="text_history_holder_secondary">在【浏览】部分找到要读的内容</string>
<string name="text_local_holder_primary">先保存内容</string>
<string name="text_local_holder_secondary">从在线来源保存或导入文件.</string>
<string name="text_local_holder_secondary">从在线来源保存或导入文件。</string>
<string name="manga_shelf">书架</string>
<string name="recent_manga">最近</string>
<string name="pages_animation">页面动画</string>
<string name="pages_animation">翻页动画</string>
<string name="manga_save_location">下载文件夹</string>
<string name="not_available">不详</string>
<string name="not_available">不可用</string>
<string name="cannot_find_available_storage">没有可用的存储空间</string>
<string name="other_storage">其他存储</string>
<string name="done">完成</string>
<string name="all_favourites">所有收藏夹</string>
<string name="favourites_category_empty">空分类</string>
<string name="all_favourites">所有收藏</string>
<string name="favourites_category_empty">空白分类</string>
<string name="read_later">稍后阅读</string>
<string name="updates">更新</string>
<string name="updates">更新内容</string>
<string name="text_feed_holder">你正在阅读的新章节显示在这里</string>
<string name="search_results">搜索结果</string>
<string name="new_version_s">新版本: %s</string>
<string name="clear_updates_feed">清除更新源</string>
<string name="clear_updates_feed">清除订阅更新记录</string>
<string name="updates_feed_cleared">已清除</string>
<string name="rotate_screen">旋转屏幕</string>
<string name="update">更新</string>
<string name="feed_will_update_soon">源更新即将开始</string>
<string name="feed_will_update_soon">订阅更新即将开始</string>
<string name="track_sources">查找更新</string>
<string name="dont_check">不要检查</string>
<string name="enter_password">输入密码</string>
@@ -167,14 +167,14 @@
<string name="zoom_mode_fit_center">适应中心</string>
<string name="zoom_mode_fit_height">适应高度</string>
<string name="zoom_mode_fit_width">适应宽度</string>
<string name="zoom_mode_keep_start">从头开始</string>
<string name="zoom_mode_keep_start">保持原始比例</string>
<string name="black_dark_theme">黑色</string>
<string name="black_dark_theme_summary">在AMOLED屏幕上使用更少电池</string>
<string name="backup_restore">备份和还原</string>
<string name="black_dark_theme_summary">在AMOLED屏幕上更省电</string>
<string name="backup_restore">备份与恢复</string>
<string name="create_backup">创建数据备份</string>
<string name="restore_backup">从备份中恢复</string>
<string name="data_restored">恢复</string>
<string name="clear_feed">清除文件</string>
<string name="clear_feed">清除订阅</string>
<string name="text_clear_updates_feed_prompt">永久地清除所有的更新历史?</string>
<string name="check_for_new_chapters">检查新的章节</string>
<string name="reverse">倒序</string>
@@ -185,64 +185,64 @@
<string name="protect_application_subtitle">输入密码以启动应用程序</string>
<string name="confirm">确认</string>
<string name="password_length_hint">密码必须是4个字符或以上</string>
<string name="text_clear_search_history_prompt">永久地删除所有最近的搜索查询?</string>
<string name="text_clear_search_history_prompt">永久删除所有搜索记录?</string>
<string name="welcome">欢迎</string>
<string name="backup_saved">保存备份</string>
<string name="tracker_warning">一些设备有不同的系统行为, 这可能会破坏后台任务.</string>
<string name="read_more">阅读更多</string>
<string name="backup_saved">备份已保存</string>
<string name="tracker_warning">一些设备有不同的系统行为,这可能会破坏后台任务。</string>
<string name="read_more">了解详情</string>
<string name="queued">排队</string>
<string name="chapter_is_missing">该章缺失</string>
<string name="about_app_translation_summary">翻译此应用程序</string>
<string name="about_app_translation">翻译</string>
<string name="auth_complete">授权</string>
<string name="auth_not_supported_by">不支持在%s上登录</string>
<string name="text_clear_cookies_prompt">你将退出登录所有来源</string>
<string name="text_clear_cookies_prompt">你将在所有漫画源退出登录</string>
<string name="genres">类型</string>
<string name="state_ongoing">连载中</string>
<string name="state_finished">已完结</string>
<string name="system_default">默认</string>
<string name="exclude_nsfw_from_history">将NSFW漫画排除在历史之外</string>
<string name="exclude_nsfw_from_history">从历史中排除NSFW漫画</string>
<string name="show_pages_numbers">页数</string>
<string name="enabled_sources">使用图源</string>
<string name="available_sources">现有图源</string>
<string name="screenshots_policy">屏幕截图</string>
<string name="enabled_sources">已用漫画源</string>
<string name="available_sources">可用漫画源</string>
<string name="screenshots_policy">截图策略</string>
<string name="screenshots_allow">允许</string>
<string name="screenshots_block_nsfw">禁止18+</string>
<string name="screenshots_block_all">始终阻止</string>
<string name="suggestions">建议</string>
<string name="suggestions_enable">启用建议</string>
<string name="text_suggestion_holder">开始阅读漫画,你会得到个性化的建议</string>
<string name="exclude_nsfw_from_suggestions">请勿推荐18+漫画</string>
<string name="screenshots_block_nsfw">屏蔽 NSFW</string>
<string name="screenshots_block_all">始终屏蔽</string>
<string name="suggestions">漫画推荐</string>
<string name="suggestions_enable">启用漫画推荐</string>
<string name="text_suggestion_holder">开始阅读漫画,你会获取个性化推荐</string>
<string name="exclude_nsfw_from_suggestions">请勿推荐 NSFW 漫画</string>
<string name="enabled">启用</string>
<string name="disabled">禁用</string>
<string name="filter_load_error">无法加载流派列表</string>
<string name="reset_filter">重置过滤器</string>
<string name="onboard_text">选择你想看的漫画的语言. 你可以在以后的设置中改变它.</string>
<string name="only_using_wifi">只在Wi-Fi上使用</string>
<string name="filter_load_error">无法加载分类列表</string>
<string name="reset_filter">重置筛选</string>
<string name="onboard_text">选择您想阅读漫画的语言。稍候您可以在设置中更改它。</string>
<string name="only_using_wifi">仅 Wi-Fi</string>
<string name="always">总是</string>
<string name="preload_pages">预加载页面</string>
<string name="logged_in_as">以%s身份登录</string>
<string name="chapters_empty">这部漫画中没有章节</string>
<string name="appearance">外观</string>
<string name="suggestions_excluded_genres_summary">指定您不希望在建议中看到的类型</string>
<string name="text_delete_local_manga_batch">从设备中永久删除所选项目\?</string>
<string name="removal_completed">删除已完成</string>
<string name="suggestions_excluded_genres_summary">指定您不希望在推荐中看到的分类</string>
<string name="text_delete_local_manga_batch">从设备中永久删除所选项目?</string>
<string name="removal_completed">删除完毕</string>
<string name="download_slowdown">下载速度减慢</string>
<string name="download_slowdown_summary">有助于避免阻断你的IP地址</string>
<string name="local_manga_processing">保存的漫画处理</string>
<string name="local_manga_processing">已保存漫画处理中</string>
<string name="chapters_will_removed_background">章节将在后台被删除</string>
<string name="hide">隐藏</string>
<string name="show_notification_new_chapters_off">你将不会收到通知但新的章节将在列表中突出显示</string>
<string name="notifications_enable">启用通知</string>
<string name="name">命名</string>
<string name="name">名称</string>
<string name="edit">编辑</string>
<string name="edit_category">编辑分类</string>
<string name="empty_favourite_categories">没有收藏夹分类</string>
<string name="empty_favourite_categories">没有收藏分类</string>
<string name="bookmark_add">添加书签</string>
<string name="bookmark_remove">删除书签</string>
<string name="bookmarks">书签</string>
<string name="bookmark_removed">删除书签</string>
<string name="bookmark_added">添加书签</string>
<string name="bookmark_removed">书签已删除</string>
<string name="bookmark_added">书签已添加</string>
<string name="undo">撤销</string>
<string name="removed_from_history">从历史中删除</string>
<string name="dns_over_https">DNS over HTTPS</string>
@@ -250,8 +250,8 @@
<string name="detect_reader_mode">自动检测阅读器模式</string>
<string name="detect_reader_mode_summary">自动检测漫画是否为条漫</string>
<string name="disable_battery_optimization">禁用电池优化</string>
<string name="disable_battery_optimization_summary">帮助进行背景更新检查</string>
<string name="crash_text">出了点问题. 请向开发人员提交一份错误报告以帮助我们修复它.</string>
<string name="disable_battery_optimization_summary">有助于进行后台更新检查</string>
<string name="crash_text">出了点问题。请向开发人员提交一份错误报告以帮助我们修复它。</string>
<string name="send">发送</string>
<string name="disable_all">全部禁用</string>
<string name="status_planned">计划</string>
@@ -263,13 +263,13 @@
<string name="status_re_reading">重读</string>
<string name="status_completed">完成</string>
<string name="use_fingerprint">使用指纹</string>
<string name="appwidget_shelf_description">你喜欢的漫画</string>
<string name="appwidget_shelf_description">你收藏的漫画</string>
<string name="appwidget_recent_description">您最近阅读的漫画</string>
<string name="show_reading_indicators_summary">在历史和收藏夹中显示阅读百分比</string>
<string name="show_reading_indicators">显示阅读进度指标</string>
<string name="show_reading_indicators_summary">在历史和收藏中显示阅读百分比</string>
<string name="show_reading_indicators">显示阅读进度</string>
<string name="data_deletion">数据删除</string>
<string name="exclude_nsfw_from_history_summary">标记为NSFW的漫画将永远不会被添加到历史中你的进度也不会被保存</string>
<string name="clear_cookies_summary">可以在出现一些问题时提供帮助. 所有授权将被视为无效</string>
<string name="exclude_nsfw_from_history_summary">标记为 NSFW 的漫画将永远不会被添加到历史中,你的阅读进度也不会被保存</string>
<string name="clear_cookies_summary">能对部分问题起到一点作用。所有网站授权将失效</string>
<string name="show_all">显示全部</string>
<string name="manga_error_description_pattern">错误详情:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1.尝试&lt;a href=%2$s&gt;在网络浏览器中打开漫画&lt;/a&gt;以确保在其来源中可用&lt;br&gt;2. 请确保您使用的是&lt;a href=kotatsu://about&gt;最新版本的Kotatsu&lt;/a&gt;&lt;br&gt;3.如果可用,请向开发人员发送错误报告。</string>
<string name="invalid_domain_message">无效域名</string>
@@ -289,23 +289,23 @@
<string name="bookmarks_removed">书签已移除</string>
<string name="history_cleared">历史已清除</string>
<string name="manage">管理</string>
<string name="no_bookmarks_yet">还没有书签</string>
<string name="no_bookmarks_yet">尚无书签</string>
<string name="no_bookmarks_summary">您可以在阅读漫画时创建书签</string>
<string name="no_manga_sources">无漫画源</string>
<string name="no_manga_sources_text">启用漫画源以在线阅读漫画</string>
<string name="no_manga_sources_text">启用漫画源即可在线阅读漫画</string>
<string name="random">随机</string>
<string name="categories_delete_confirm">您确定要删除选定的收藏夹吗?
\n所有收藏夹中的漫画将丢失且无法恢复。</string>
<string name="categories_delete_confirm">您确定要删除选定的收藏分类吗?
\n该分类中的所有漫画将丢失且无法恢复。</string>
<string name="reorder">重新排序</string>
<string name="empty">留空</string>
<string name="empty">空分类</string>
<string name="explore">浏览</string>
<string name="automatic_scroll">自动滚动</string>
<string name="reader_info_bar">在阅读器中显示信息栏</string>
<string name="comics_archive">漫画压缩包</string>
<string name="folder_with_images">图片文件夹</string>
<string name="importing_manga">漫画导入中</string>
<string name="reader_info_pattern">Ch. %1$d/%2$d Pg. %3$d/%4$d</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">%1$d 的 %2$d 启用</string>
<string name="reader_info_pattern">章节 %1$d/%2$d 页码 %3$d/%4$d</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">已启用 %1$d / %2$d</string>
<string name="size_s">大小:%s</string>
<string name="confirm_exit">再按一次返回键退出</string>
<string name="exit_confirmation_summary">按两次返回键退出应用</string>
@@ -322,10 +322,10 @@
<string name="import_completed">导入完毕</string>
<string name="import_completed_hint">您可以从存储中删除原文件以节省空间</string>
<string name="import_will_start_soon">即将开始导入</string>
<string name="feed">订阅源</string>
<string name="feed">订阅</string>
<string name="memory_usage_pattern">%s - %s</string>
<string name="not_found_404">内容未找到或已移除</string>
<string name="reader_control_ltr_summary">点击屏幕右侧或按下右键翻到下一页</string>
<string name="reader_control_ltr_summary">点击屏幕右侧或按下右键始终翻到下一页</string>
<string name="reader_control_ltr">高效阅读器控制</string>
<string name="history_shortcuts_summary">长按应用图标显示最近阅读的漫画</string>
<string name="history_shortcuts">显示最近阅读漫画的快捷方式</string>
@@ -337,21 +337,21 @@
<string name="text_unsaved_changes_prompt">保存还是放弃未保存的更改?</string>
<string name="discard">放弃</string>
<string name="error_no_space_left">设备上没有剩余空间</string>
<string name="reader_slider">显示换页滑块</string>
<string name="webtoon_zoom">Webtoon 缩放</string>
<string name="reader_slider">显示翻页滑块</string>
<string name="webtoon_zoom">条漫缩放</string>
<string name="different_languages">不同语言</string>
<string name="network_unavailable">网络不可用</string>
<string name="network_unavailable_hint">打开 Wi-Fi 或移动网络在线阅读漫画</string>
<string name="clear_new_chapters_counters">同样清除新章节信息</string>
<string name="clear_new_chapters_counters">同时清除新章节信息</string>
<string name="server_error">服务器端错误 (%1$d)。请稍后再试</string>
<string name="compact">紧凑</string>
<string name="source_disabled">已禁用图源</string>
<string name="source_disabled">漫画源已禁用</string>
<string name="prefetch_content">内容预加载</string>
<string name="mark_as_current">标为当前</string>
<string name="language">语言</string>
<string name="enable_logging">启用日志记录</string>
<string name="share_logs">分享日志</string>
<string name="enable_logging_summary">出于调试目的记录某些操作</string>
<string name="enable_logging_summary">出于调试目的记录某些操作,如果你不了解你在做什么,请不要启用该选项</string>
<string name="show_suspicious_content">显示可疑内容</string>
<string name="theme_name_dynamic">动态</string>
<string name="color_theme">颜色方案</string>
@@ -365,10 +365,10 @@
<string name="theme_name_mamimi">Mamimi</string>
<string name="theme_name_kanade">Kanade</string>
<string name="nothing_here">这里什么也没有</string>
<string name="scrobbling_empty_hint">要跟踪阅读进度,在漫画详情屏幕上选中“菜单→ 跟踪。</string>
<string name="scrobbling_empty_hint">要跟踪阅读进度,在漫画详情屏幕上选中【菜单】→【跟踪】。</string>
<string name="allow_unstable_updates">允许不稳定更新</string>
<string name="allow_unstable_updates_summary">提示更新到测试版</string>
<string name="download_started">已开始下载</string>
<string name="allow_unstable_updates_summary">接收不稳定版本更新的通知</string>
<string name="download_started">下载已开始</string>
<string name="user_agent">UserAgent 标头</string>
<string name="settings_apply_restart_required">要应用这些更改请重启程序</string>
<string name="sources_reorder_tip">点击并长按项目排序</string>
@@ -383,10 +383,10 @@
<string name="web_view_unavailable">WebView不可用:检查是否已安装WebView</string>
<string name="sync_host_description">你可以使用自建同步服务器或默认服务器。如果你不知道自己在干什么请不要修改此处。</string>
<string name="mirror_switching">自动选择镜像</string>
<string name="mirror_switching_summary">如果存在可用镜像,在出错时自动切换域名</string>
<string name="mirror_switching_summary">如果存在可用镜像,在出错时自动切换漫画源域名</string>
<string name="paused">已暂停</string>
<string name="downloads_wifi_only_summary">切换到移动网络时停止下载</string>
<string name="remove_completed">移除已完成</string>
<string name="remove_completed">删除完毕</string>
<string name="cancel_all">取消所有</string>
<string name="downloads_wifi_only">仅通过Wi-Fi下载</string>
<string name="enable">启用</string>
@@ -397,17 +397,17 @@
<string name="resume">恢复</string>
<string name="ignore_ssl_errors">忽略SSL错误</string>
<string name="text_downloads_list_holder">没有下载项</string>
<string name="downloads_resumed">下载已经恢复</string>
<string name="downloads_paused">暂停下载</string>
<string name="downloads_removed">下载已被移除</string>
<string name="downloads_cancelled">下载被取消</string>
<string name="downloads_resumed">下载已恢复</string>
<string name="downloads_paused">下载已暂停</string>
<string name="downloads_removed">下载已移除</string>
<string name="downloads_cancelled">下载已取消</string>
<string name="suggestions_enable_prompt">你想要接收个性化的漫画推荐吗?</string>
<string name="suggestion_manga">推荐:%s</string>
<string name="suggestions_notifications_summary">偶尔显示建议漫画通知</string>
<string name="suggestions_notifications_summary">偶尔显示漫画推荐通知</string>
<string name="more">更多</string>
<string name="cancel_all_downloads_confirm">所有进行中的下载都将被取消,未下载完成的数据将丢失</string>
<string name="remove_completed_downloads_confirm">你的下载历史将会永久删除</string>
<string name="sync_auth_hint">你可以登陆一个已有账号或创建新账号</string>
<string name="sync_auth_hint">你可以登陆已有账号或创建新账号</string>
<string name="address">地址</string>
<string name="clear_network_cache">清除网络缓存</string>
<string name="proxy">代理</string>
@@ -420,10 +420,61 @@
<string name="clear_source_cookies_summary">仅清除特定域名的 cookies。大多数情况下会使网站授权失效</string>
<string name="data_and_privacy">数据与隐私</string>
<string name="reader_info_bar_summary">在屏幕顶部显示当前时间和阅读进度</string>
<string name="webtoon_zoom_summary">条漫模式下允许以手势缩放</string>
<string name="webtoon_zoom_summary">条漫模式下允许使用缩放手势</string>
<string name="invalid_port_number">无效端口</string>
<string name="no_access_to_file">您无法访问该文件或目录</string>
<string name="local_manga_directories">本地漫画目录</string>
<string name="port">端口</string>
<string name="manga_branch_title_template">%1$s (%2$s)</string>
<string name="download_option_all_unread">所有未读章节</string>
<string name="pick_custom_directory">选择自定义目录</string>
<string name="password">密码</string>
<string name="download_option_whole_manga">整部漫画</string>
<string name="download_option_manual_selection">手动选择章节</string>
<string name="description">简介</string>
<string name="images_proxy_title">图片加载优化</string>
<string name="username">用户名</string>
<string name="download_option_all_unread_b">所有未读章节 (%s)</string>
<string name="authorization_optional">授权 (可选项)</string>
<string name="download_option_first_n_chapters">前 %s 章</string>
<string name="downloaded">已下载</string>
<string name="custom_directory">自定义目录</string>
<string name="pages_animation_summary">翻页动画</string>
<string name="download_option_next_unread_n_chapters">后 %s 章</string>
<string name="images_procy_description">尽可能使用 wsrv.nl 代理服务减少流量使用并加快图片加载</string>
<string name="invert_colors">颜色反转</string>
<string name="related_manga">相关漫画</string>
<string name="voice_search">语音搜索</string>
<string name="this_month">本月</string>
<string name="languages">语言</string>
<string name="captcha_required_summary">%s 需通过验证码才能正常工作</string>
<string name="progress">阅读进度</string>
<string name="error_corrupted_file">无效数据回传或文件已损坏</string>
<string name="related_manga_summary">显示相关漫画。可能并不准确或缺失</string>
<string name="tracker_wifi_only_summary">使用计量网络时停止检查新章节</string>
<string name="order_added">添加日期</string>
<string name="on_device">本地</string>
<string name="moved_to_top">移动到顶部</string>
<string name="data_not_restored_text">请确认你选择了正确的备份文件</string>
<string name="unknown">未知</string>
<string name="in_progress">进行中</string>
<string name="items_limit_exceeded">无可添加项</string>
<string name="data_not_restored">数据未恢复</string>
<string name="directories">目录</string>
<string name="manage_categories">漫画类别</string>
<string name="color_light">浅色</string>
<string name="search_hint">输入漫画名、漫画类型或漫画源名称</string>
<string name="main_screen_sections">主页栏目</string>
<string name="advanced">高级</string>
<string name="color_dark">深色</string>
<string name="too_many_requests_message">请求次数过多,稍候再尝试</string>
<string name="suggestions_wifi_only_summary">使用计量网络时停止推荐漫画</string>
<string name="default_section">默认栏目</string>
<string name="background">阅读背景色</string>
<string name="manga_list">漫画列表</string>
<string name="disable_nsfw">禁用 NSFW 源</string>
<string name="color_white">白色</string>
<string name="to_top">置顶</string>
<string name="show">显示</string>
<string name="color_black">黑色</string>
</resources>

View File

@@ -6,4 +6,12 @@
<item name="fast_scroller" type="id" />
<item name="group_branches" type="id" />
<item name="layout_tip" type="id" />
<!-- Navigation -->
<item name="nav_history" type="id" />
<item name="nav_favorites" type="id" />
<item name="nav_local" type="id" />
<item name="nav_explore" type="id" />
<item name="nav_feed" type="id" />
<item name="nav_suggestions" type="id" />
<item name="nav_bookmarks" type="id" />
</resources>

View File

@@ -479,4 +479,10 @@
<string name="default_section">Default section</string>
<string name="manga_list">Manga list</string>
<string name="error_corrupted_file">Invalid data is returned or file is corrupted</string>
<string name="on_device">On device</string>
<string name="directories">Directories</string>
<string name="main_screen_sections">Main screen sections</string>
<string name="items_limit_exceeded">No more items can be added</string>
<string name="to_top">To top</string>
<string name="moved_to_top">Moved to top</string>
</resources>

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