Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f6e5232b | ||
|
|
f205c1b3dc | ||
|
|
4b2a487c37 | ||
|
|
726ac21974 | ||
|
|
6b35216949 | ||
|
|
22cae62f17 | ||
|
|
4733caf2e6 | ||
|
|
d49103de1f | ||
|
|
414bab7ce3 | ||
|
|
64c1873eb5 | ||
|
|
06a0b5829b | ||
|
|
0ce2870c8b | ||
|
|
f59027666b | ||
|
|
8513bc6daf | ||
|
|
cceaefc896 | ||
|
|
1d32f53bdd | ||
|
|
0e98dd8695 | ||
|
|
119b7c2ac7 | ||
|
|
5701862661 | ||
|
|
5590ab7c8a | ||
|
|
9fde0106be | ||
|
|
e73f077dc5 | ||
|
|
c37458d43a | ||
|
|
e2fcfcc7a8 | ||
|
|
7a3b2a9bb4 |
40
README.md
40
README.md
@@ -1,24 +1,16 @@
|
|||||||
|
> [!IMPORTANT]
|
||||||
|
> In light of recent challenges — including threating actions from Kakao Entertainment Corp and upcoming Google’s
|
||||||
|
> [new sideloading policy](https://f-droid.org/ru/2025/10/28/sideloading.html) — we’ve made the difficult decision to shut down Kotatsu and end its support. We’re deeply grateful
|
||||||
|
> to everyone who contributed and to the amazing community that grew around this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://kotatsu.app">
|
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in
|
||||||
<img src="./.github/assets/vtuber.png" alt="Kotatsu Logo" title="Kotatsu" width="600"/>
|
online content sources.**
|
||||||
</a>
|
|
||||||
|
|
||||||
# [Kotatsu](https://kotatsu.app)
|
 [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
||||||
|
|
||||||
**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.**
|
|
||||||
|
|
||||||
   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE)
|
|
||||||
|
|
||||||
### Download
|
|
||||||
|
|
||||||
<div align="left">
|
|
||||||
|
|
||||||
* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature.
|
|
||||||
* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing.
|
|
||||||
* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (Unstable, use at your own risk). Application has a built-in self-updating feature.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Main Features
|
### Main Features
|
||||||
|
|
||||||
@@ -86,7 +78,8 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
|||||||
|
|
||||||
</br>
|
</br>
|
||||||
|
|
||||||
**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
**📌 Pull requests are welcome, if you want:
|
||||||
|
See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
|
||||||
|
|
||||||
### Certificate fingerprints
|
### Certificate fingerprints
|
||||||
|
|
||||||
@@ -104,7 +97,9 @@ please head over to the [Weblate project page](https://hosted.weblate.org/engage
|
|||||||
|
|
||||||
<div align="left">
|
<div align="left">
|
||||||
|
|
||||||
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions.
|
You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications
|
||||||
|
to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build &
|
||||||
|
install instructions.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,6 +107,9 @@ You may copy, distribute and modify the software as long as you track changes/da
|
|||||||
|
|
||||||
<div align="left">
|
<div align="left">
|
||||||
|
|
||||||
The developers of this application do not have any affiliation with the content available in the app and does not store or distribute any content. This application should be considered a web browser, all content that can be found using this application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website where the content is hosted.
|
The developers of this application do not have any affiliation with the content available in the app and does not store
|
||||||
|
or distribute any content. This application should be considered a web browser, all content that can be found using this
|
||||||
|
application is freely available on the Internet. All DMCA takedown requests should be sent to the owners of the website
|
||||||
|
where the content is hosted.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1032
|
versionCode = 1033
|
||||||
versionName = '9.4'
|
versionName = '9.4.1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
@@ -155,6 +155,9 @@ dependencies {
|
|||||||
implementation libs.androidx.work.runtime
|
implementation libs.androidx.work.runtime
|
||||||
implementation libs.guava
|
implementation libs.guava
|
||||||
|
|
||||||
|
// Foldable/Window layout
|
||||||
|
implementation libs.androidx.window
|
||||||
|
|
||||||
implementation libs.androidx.room.runtime
|
implementation libs.androidx.room.runtime
|
||||||
implementation libs.androidx.room.ktx
|
implementation libs.androidx.room.ktx
|
||||||
ksp libs.androidx.room.compiler
|
ksp libs.androidx.room.compiler
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.koitharu.kotatsu.core.exceptions
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
|
||||||
|
class EmptyMangaException(
|
||||||
|
val reason: EmptyMangaReason?,
|
||||||
|
val manga: Manga,
|
||||||
|
cause: Throwable?
|
||||||
|
) : IllegalStateException(cause)
|
||||||
@@ -8,13 +8,15 @@ import androidx.collection.MutableScatterMap
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.fragment.app.FragmentManager
|
import androidx.fragment.app.FragmentManager
|
||||||
import dagger.assisted.Assisted
|
import androidx.lifecycle.Lifecycle
|
||||||
import dagger.assisted.AssistedFactory
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import dagger.assisted.AssistedInject
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||||
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
|
|||||||
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
|
||||||
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
import org.koitharu.kotatsu.core.util.ext.isHttpUrl
|
||||||
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
import org.koitharu.kotatsu.core.util.ext.restartApplication
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -32,14 +35,15 @@ import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredExcept
|
|||||||
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper
|
||||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||||
import java.security.cert.CertPathValidatorException
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
import javax.net.ssl.SSLException
|
import javax.net.ssl.SSLException
|
||||||
import kotlin.coroutines.Continuation
|
import kotlin.coroutines.Continuation
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class ExceptionResolver @AssistedInject constructor(
|
class ExceptionResolver private constructor(
|
||||||
@Assisted private val host: Host,
|
private val host: Host,
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||||
) {
|
) {
|
||||||
@@ -56,10 +60,11 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun showErrorDetails(e: Throwable, url: String? = null) {
|
fun showErrorDetails(e: Throwable, url: String? = null) {
|
||||||
host.router()?.showErrorDialog(e, url)
|
host.router.showErrorDialog(e, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun resolve(e: Throwable): Boolean = when (e) {
|
suspend fun resolve(e: Throwable): Boolean = host.lifecycleScope.async {
|
||||||
|
when (e) {
|
||||||
is CloudFlareProtectedException -> resolveCF(e)
|
is CloudFlareProtectedException -> resolveCF(e)
|
||||||
is AuthRequiredException -> resolveAuthException(e.source)
|
is AuthRequiredException -> resolveAuthException(e.source)
|
||||||
is SSLException,
|
is SSLException,
|
||||||
@@ -71,7 +76,7 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
is InteractiveActionRequiredException -> resolveBrowserAction(e)
|
||||||
|
|
||||||
is ProxyConfigException -> {
|
is ProxyConfigException -> {
|
||||||
host.router()?.openProxySettings()
|
host.router.openProxySettings()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +85,16 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is EmptyMangaException -> {
|
||||||
|
when (e.reason) {
|
||||||
|
EmptyMangaReason.NO_CHAPTERS -> openAlternatives(e.manga)
|
||||||
|
EmptyMangaReason.LOADING_ERROR -> Unit
|
||||||
|
EmptyMangaReason.RESTRICTED -> host.router.openBrowser(e.manga)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
is UnsupportedSourceException -> {
|
is UnsupportedSourceException -> {
|
||||||
e.manga?.let { openAlternatives(it) }
|
e.manga?.let { openAlternatives(it) }
|
||||||
false
|
false
|
||||||
@@ -99,6 +114,7 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
|
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
|
}.await()
|
||||||
|
|
||||||
private suspend fun resolveBrowserAction(
|
private suspend fun resolveBrowserAction(
|
||||||
e: InteractiveActionRequiredException
|
e: InteractiveActionRequiredException
|
||||||
@@ -118,11 +134,11 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun openInBrowser(url: String) {
|
private fun openInBrowser(url: String) {
|
||||||
host.router()?.openBrowser(url, null, null)
|
host.router.openBrowser(url, null, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openAlternatives(manga: Manga) {
|
private fun openAlternatives(manga: Manga) {
|
||||||
host.router()?.openAlternatives(manga)
|
host.router.openAlternatives(manga)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleActivityResult(tag: String, result: Boolean) {
|
private fun handleActivityResult(tag: String, result: Boolean) {
|
||||||
@@ -130,7 +146,7 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showSslErrorDialog() {
|
private fun showSslErrorDialog() {
|
||||||
val ctx = host.getContext() ?: return
|
val ctx = host.context ?: return
|
||||||
if (settings.isSSLBypassEnabled) {
|
if (settings.isSSLBypassEnabled) {
|
||||||
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(ctx, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -147,27 +163,65 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
}.show()
|
}.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun Host.withContext(block: Context.() -> Unit) {
|
class Factory @Inject constructor(
|
||||||
getContext()?.apply(block)
|
private val settings: AppSettings,
|
||||||
|
private val scrobblerAuthHelperProvider: Provider<ScrobblerAuthHelper>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun create(fragment: Fragment) = ExceptionResolver(
|
||||||
|
host = Host.FragmentHost(fragment),
|
||||||
|
settings = settings,
|
||||||
|
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun create(activity: FragmentActivity) = ExceptionResolver(
|
||||||
|
host = Host.ActivityHost(activity),
|
||||||
|
settings = settings,
|
||||||
|
scrobblerAuthHelperProvider = scrobblerAuthHelperProvider,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Host.router(): AppRouter? = when (this) {
|
private sealed interface Host : ActivityResultCaller, LifecycleOwner {
|
||||||
is FragmentActivity -> router
|
|
||||||
is Fragment -> router
|
val context: Context?
|
||||||
else -> null
|
|
||||||
|
val router: AppRouter
|
||||||
|
|
||||||
|
val fragmentManager: FragmentManager
|
||||||
|
|
||||||
|
inline fun withContext(block: Context.() -> Unit) {
|
||||||
|
context?.apply(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Host : ActivityResultCaller {
|
class ActivityHost(val activity: FragmentActivity) : Host,
|
||||||
|
ActivityResultCaller by activity,
|
||||||
|
LifecycleOwner by activity {
|
||||||
|
|
||||||
fun getChildFragmentManager(): FragmentManager
|
override val context: Context
|
||||||
|
get() = activity
|
||||||
|
|
||||||
fun getContext(): Context?
|
override val router: AppRouter
|
||||||
|
get() = activity.router
|
||||||
|
|
||||||
|
override val fragmentManager: FragmentManager
|
||||||
|
get() = activity.supportFragmentManager
|
||||||
}
|
}
|
||||||
|
|
||||||
@AssistedFactory
|
class FragmentHost(val fragment: Fragment) : Host,
|
||||||
interface Factory {
|
ActivityResultCaller by fragment {
|
||||||
|
|
||||||
fun create(host: Host): ExceptionResolver
|
override val context: Context?
|
||||||
|
get() = fragment.context
|
||||||
|
|
||||||
|
override val router: AppRouter
|
||||||
|
get() = fragment.router
|
||||||
|
|
||||||
|
override val fragmentManager: FragmentManager
|
||||||
|
get() = fragment.childFragmentManager
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle
|
||||||
|
get() = fragment.viewLifecycleOwner.lifecycle
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -187,6 +241,12 @@ class ExceptionResolver @AssistedInject constructor(
|
|||||||
|
|
||||||
is InteractiveActionRequiredException -> R.string._continue
|
is InteractiveActionRequiredException -> R.string._continue
|
||||||
|
|
||||||
|
is EmptyMangaException -> when (e.reason) {
|
||||||
|
EmptyMangaReason.RESTRICTED -> if (e.manga.publicUrl.isHttpUrl()) R.string.open_in_browser else 0
|
||||||
|
EmptyMangaReason.NO_CHAPTERS -> R.string.alternatives
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,12 @@ class AppRouter private constructor(
|
|||||||
startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
|
startActivity(browserIntent(contextOrNull() ?: return, url, source, title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openBrowser(manga: Manga) = openBrowser(
|
||||||
|
url = manga.publicUrl,
|
||||||
|
source = manga.source,
|
||||||
|
title = manga.title,
|
||||||
|
)
|
||||||
|
|
||||||
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
fun openColorFilterConfig(manga: Manga, page: MangaPage) {
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
|
Intent(contextOrNull(), ColorFilterConfigActivity::class.java)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
get() = prefs.getBoolean(KEY_READER_DOUBLE_PAGES, false)
|
||||||
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_PAGES, value) }
|
||||||
|
|
||||||
|
var isReaderDoubleOnFoldable: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_READER_DOUBLE_FOLDABLE, false)
|
||||||
|
set(value) = prefs.edit { putBoolean(KEY_READER_DOUBLE_FOLDABLE, value) }
|
||||||
|
|
||||||
@get:FloatRange(0.0, 1.0)
|
@get:FloatRange(0.0, 1.0)
|
||||||
var readerDoublePagesSensitivity: Float
|
var readerDoublePagesSensitivity: Float
|
||||||
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
get() = prefs.getFloat(KEY_READER_DOUBLE_PAGES_SENSITIVITY, 0.5f)
|
||||||
@@ -681,7 +685,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||||
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity"
|
const val KEY_READER_DOUBLE_PAGES_SENSITIVITY = "reader_double_pages_sensitivity_2"
|
||||||
|
const val KEY_READER_DOUBLE_FOLDABLE = "reader_double_foldable"
|
||||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||||
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
const val KEY_READER_NAVIGATION_INVERTED = "reader_navigation_inverted"
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import androidx.appcompat.R as appcompatR
|
|||||||
|
|
||||||
abstract class BaseActivity<B : ViewBinding> :
|
abstract class BaseActivity<B : ViewBinding> :
|
||||||
AppCompatActivity(),
|
AppCompatActivity(),
|
||||||
ExceptionResolver.Host,
|
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
ScreenshotPolicyHelper.ContentContainer {
|
ScreenshotPolicyHelper.ContentContainer {
|
||||||
|
|
||||||
@@ -87,10 +86,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
|||||||
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
@Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR)
|
||||||
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
override fun setContentView(view: View?) = throw UnsupportedOperationException()
|
||||||
|
|
||||||
override fun getContext() = this
|
|
||||||
|
|
||||||
override fun getChildFragmentManager(): FragmentManager = supportFragmentManager
|
|
||||||
|
|
||||||
protected fun setContentView(binding: B) {
|
protected fun setContentView(binding: B) {
|
||||||
this.viewBinding = binding
|
this.viewBinding = binding
|
||||||
super.setContentView(binding.root)
|
super.setContentView(binding.root)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
|||||||
|
|
||||||
abstract class BaseFragment<B : ViewBinding> :
|
abstract class BaseFragment<B : ViewBinding> :
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
Fragment(),
|
Fragment() {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
var viewBinding: B? = null
|
var viewBinding: B? = null
|
||||||
private set
|
private set
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ import com.google.android.material.R as materialR
|
|||||||
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
|
||||||
PreferenceFragmentCompat(),
|
PreferenceFragmentCompat(),
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener,
|
||||||
RecyclerViewOwner,
|
RecyclerViewOwner {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
protected lateinit var exceptionResolver: ExceptionResolver
|
protected lateinit var exceptionResolver: ExceptionResolver
|
||||||
private set
|
private set
|
||||||
|
|||||||
@@ -32,8 +32,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
|||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment(),
|
||||||
OnApplyWindowInsetsListener,
|
OnApplyWindowInsetsListener {
|
||||||
ExceptionResolver.Host {
|
|
||||||
|
|
||||||
private var waitingForDismissAllowingStateLoss = false
|
private var waitingForDismissAllowingStateLoss = false
|
||||||
private var isFitToContentsDisabled = false
|
private var isFitToContentsDisabled = false
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.exceptions.CaughtException
|
|||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||||
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
import org.koitharu.kotatsu.core.exceptions.IncompatiblePluginException
|
||||||
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
|
||||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||||
@@ -103,6 +104,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w
|
|||||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||||
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
is NonFileUriException -> resources.getString(R.string.error_non_file_uri)
|
||||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||||
|
is EmptyMangaException -> reason?.let { resources.getString(it.msgResId) } ?: cause?.getDisplayMessage(resources)
|
||||||
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration)
|
||||||
is SyncApiException,
|
is SyncApiException,
|
||||||
is ContentUnavailableException -> message
|
is ContentUnavailableException -> message
|
||||||
@@ -167,6 +169,8 @@ fun Throwable.getCauseUrl(): String? = when (this) {
|
|||||||
is CloudFlareProtectedException -> url
|
is CloudFlareProtectedException -> url
|
||||||
is InteractiveActionRequiredException -> url
|
is InteractiveActionRequiredException -> url
|
||||||
is HttpStatusException -> url
|
is HttpStatusException -> url
|
||||||
|
is UnsupportedSourceException -> manga?.publicUrl?.takeIf { it.isHttpUrl() }
|
||||||
|
is EmptyMangaException -> manga.publicUrl.takeIf { it.isHttpUrl() }
|
||||||
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
is HttpException -> (response.delegate as? Response)?.request?.url?.toString()
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
|||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
import org.koitharu.kotatsu.reader.data.filterChapters
|
import org.koitharu.kotatsu.reader.data.filterChapters
|
||||||
@@ -50,6 +51,9 @@ data class MangaDetails(
|
|||||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||||
?.nullIfEmpty()
|
?.nullIfEmpty()
|
||||||
|
|
||||||
|
val isRestricted: Boolean
|
||||||
|
get() = manga.state == MangaState.RESTRICTED
|
||||||
|
|
||||||
private val mergedManga by lazy {
|
private val mergedManga by lazy {
|
||||||
if (localManga == null) {
|
if (localManga == null) {
|
||||||
// fast path
|
// fast path
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(),
|
|||||||
|
|
||||||
override fun onStateChanged(sheet: View, newState: Int) {
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
val binding = viewBinding ?: return
|
val binding = viewBinding ?: return
|
||||||
binding.layoutTouchBlock.isTouchEventsAllowed = newState != STATE_COLLAPSED
|
binding.layoutTouchBlock.isTouchEventsAllowed = dialog != null || newState != STATE_COLLAPSED
|
||||||
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,9 +133,7 @@ class FilterSheetFragment : BaseAdaptiveSheet<SheetFilterBinding>(),
|
|||||||
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
layoutBody.updatePadding(top = layoutBody.paddingBottom)
|
||||||
scrollView.scrollIndicators = 0
|
scrollView.scrollIndicators = 0
|
||||||
buttonDone.isVisible = false
|
buttonDone.isVisible = false
|
||||||
this.root.updateLayoutParams {
|
this.root.layoutParams?.height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
height = ViewGroup.LayoutParams.MATCH_PARENT
|
|
||||||
}
|
|
||||||
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
|
buttonSave.updateLayoutParams<LinearLayout.LayoutParams> {
|
||||||
weight = 0f
|
weight = 0f
|
||||||
width = LinearLayout.LayoutParams.WRAP_CONTENT
|
width = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui
|
|||||||
import android.app.assist.AssistContent
|
import android.app.assist.AssistContent
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -24,6 +25,8 @@ import androidx.transition.Fade
|
|||||||
import androidx.transition.Slide
|
import androidx.transition.Slide
|
||||||
import androidx.transition.TransitionManager
|
import androidx.transition.TransitionManager
|
||||||
import androidx.transition.TransitionSet
|
import androidx.transition.TransitionSet
|
||||||
|
import androidx.window.layout.FoldingFeature
|
||||||
|
import androidx.window.layout.WindowInfoTracker
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -31,7 +34,9 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -110,6 +115,9 @@ class ReaderActivity :
|
|||||||
private lateinit var readerManager: ReaderManager
|
private lateinit var readerManager: ReaderManager
|
||||||
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
private val hideUiRunnable = Runnable { setUiIsVisible(false) }
|
||||||
|
|
||||||
|
// Tracks whether the foldable device is in an unfolded state (half-opened or flat)
|
||||||
|
private var isFoldUnfolded: Boolean = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
setContentView(ActivityReaderBinding.inflate(layoutInflater))
|
||||||
@@ -187,6 +195,11 @@ class ReaderActivity :
|
|||||||
viewBinding.zoomControl.isVisible = it
|
viewBinding.zoomControl.isVisible = it
|
||||||
}
|
}
|
||||||
addMenuProvider(ReaderMenuProvider(viewModel))
|
addMenuProvider(ReaderMenuProvider(viewModel))
|
||||||
|
|
||||||
|
observeWindowLayout()
|
||||||
|
|
||||||
|
// Apply initial double-mode considering foldable setting
|
||||||
|
applyDoubleModeAuto()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getParentActivityIntent(): Intent? {
|
override fun getParentActivityIntent(): Intent? {
|
||||||
@@ -341,7 +354,17 @@ class ReaderActivity :
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
override fun onDoubleModeChanged(isEnabled: Boolean) {
|
||||||
readerManager.setDoubleReaderMode(isEnabled)
|
// Combine manual toggle with foldable auto setting
|
||||||
|
applyDoubleModeAuto(isEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyDoubleModeAuto(manualEnabled: Boolean? = null) {
|
||||||
|
val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
// Auto double-page on foldable when device is unfolded (half-opened or flat)
|
||||||
|
val autoFoldable = settings.isReaderDoubleOnFoldable && isFoldUnfolded
|
||||||
|
val manualLandscape = (manualEnabled ?: settings.isReaderDoubleOnLandscape) && isLandscape
|
||||||
|
val autoEnabled = autoFoldable || manualLandscape
|
||||||
|
readerManager.setDoubleReaderMode(autoEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setKeepScreenOn(isKeep: Boolean) {
|
private fun setKeepScreenOn(isKeep: Boolean) {
|
||||||
@@ -521,6 +544,24 @@ class ReaderActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Observe foldable window layout to auto-enable double-page if configured
|
||||||
|
private fun observeWindowLayout() {
|
||||||
|
WindowInfoTracker.getOrCreate(this)
|
||||||
|
.windowLayoutInfo(this)
|
||||||
|
.onEach { info ->
|
||||||
|
val fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||||
|
val unfolded = when (fold?.state) {
|
||||||
|
FoldingFeature.State.HALF_OPENED, FoldingFeature.State.FLAT -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
if (unfolded != isFoldUnfolded) {
|
||||||
|
isFoldUnfolded = unfolded
|
||||||
|
applyDoubleModeAuto()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(lifecycleScope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun askForIncognitoMode() {
|
private fun askForIncognitoMode() {
|
||||||
buildAlertDialog(this, isCentered = true) {
|
buildAlertDialog(this, isCentered = true) {
|
||||||
var dontAskAgain = false
|
var dontAskAgain = false
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ReaderManager(
|
|||||||
fun setDoubleReaderMode(isEnabled: Boolean) {
|
fun setDoubleReaderMode(isEnabled: Boolean) {
|
||||||
val mode = currentMode
|
val mode = currentMode
|
||||||
val prevReader = currentReader?.javaClass
|
val prevReader = currentReader?.javaClass
|
||||||
invalidateTypesMap(isEnabled && isLandscape())
|
invalidateTypesMap(isEnabled)
|
||||||
val newReader = modeMap[mode]
|
val newReader = modeMap[mode]
|
||||||
if (mode != null && newReader != prevReader) {
|
if (mode != null && newReader != prevReader) {
|
||||||
replace(mode)
|
replace(mode)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import kotlinx.coroutines.plus
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||||
|
import org.koitharu.kotatsu.core.exceptions.EmptyMangaException
|
||||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||||
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
import org.koitharu.kotatsu.core.nav.ReaderIntent
|
||||||
@@ -47,6 +48,7 @@ import org.koitharu.kotatsu.details.data.MangaDetails
|
|||||||
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
import org.koitharu.kotatsu.details.domain.DetailsInteractor
|
||||||
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
|
||||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
|
||||||
|
import org.koitharu.kotatsu.details.ui.pager.EmptyMangaReason
|
||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.history.data.HistoryRepository
|
import org.koitharu.kotatsu.history.data.HistoryRepository
|
||||||
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
|
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
|
||||||
@@ -405,9 +407,11 @@ class ReaderViewModel @Inject constructor(
|
|||||||
private fun loadImpl() {
|
private fun loadImpl() {
|
||||||
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
|
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
|
||||||
var exception: Exception? = null
|
var exception: Exception? = null
|
||||||
|
var loadedDetails: MangaDetails? = null
|
||||||
try {
|
try {
|
||||||
detailsLoadUseCase(intent, force = false)
|
detailsLoadUseCase(intent, force = false)
|
||||||
.collect { details ->
|
.collect { details ->
|
||||||
|
loadedDetails = details
|
||||||
if (mangaDetails.value == null) {
|
if (mangaDetails.value == null) {
|
||||||
mangaDetails.value = details
|
mangaDetails.value = details
|
||||||
}
|
}
|
||||||
@@ -452,9 +456,28 @@ class ReaderViewModel @Inject constructor(
|
|||||||
exception = e.mergeWith(exception)
|
exception = e.mergeWith(exception)
|
||||||
}
|
}
|
||||||
if (readingState.value == null) {
|
if (readingState.value == null) {
|
||||||
onLoadingError.call(
|
val loadedManga = loadedDetails // for smart cast
|
||||||
exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
|
if (loadedManga != null) {
|
||||||
|
mangaDetails.value = loadedManga.filterChapters(selectedBranch.value)
|
||||||
|
}
|
||||||
|
val loadingError = when {
|
||||||
|
exception != null -> exception
|
||||||
|
loadedManga == null || !loadedManga.isLoaded -> null
|
||||||
|
loadedManga.isRestricted -> EmptyMangaException(
|
||||||
|
EmptyMangaReason.RESTRICTED,
|
||||||
|
loadedManga.toManga(),
|
||||||
|
null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
loadedManga.allChapters.isEmpty() -> EmptyMangaException(
|
||||||
|
EmptyMangaReason.NO_CHAPTERS,
|
||||||
|
loadedManga.toManga(),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
} ?: IllegalStateException("Unable to load manga. This should never happen. Please report")
|
||||||
|
onLoadingError.call(loadingError)
|
||||||
} else exception?.let { e ->
|
} else exception?.let { e ->
|
||||||
// manga has been loaded but error occurred
|
// manga has been loaded but error occurred
|
||||||
errorEvent.call(e)
|
errorEvent.call(e)
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ class ReaderConfigSheet :
|
|||||||
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
|
||||||
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
binding.switchDoubleReader.isChecked = settings.isReaderDoubleOnLandscape
|
||||||
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
binding.switchDoubleReader.isEnabled = mode == ReaderMode.STANDARD || mode == ReaderMode.REVERSED
|
||||||
|
binding.switchDoubleFoldable.isChecked = settings.isReaderDoubleOnFoldable
|
||||||
|
binding.switchDoubleFoldable.isEnabled = binding.switchDoubleReader.isEnabled
|
||||||
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
binding.sliderDoubleSensitivity.setValueRounded(settings.readerDoublePagesSensitivity * 100f)
|
||||||
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
binding.sliderDoubleSensitivity.setLabelFormatter(IntPercentLabelFormatter(binding.root.context))
|
||||||
binding.adjustSensitivitySlider(withAnimation = false)
|
binding.adjustSensitivitySlider(withAnimation = false)
|
||||||
@@ -104,6 +106,7 @@ class ReaderConfigSheet :
|
|||||||
binding.buttonScrollTimer.setOnClickListener(this)
|
binding.buttonScrollTimer.setOnClickListener(this)
|
||||||
binding.buttonBookmark.setOnClickListener(this)
|
binding.buttonBookmark.setOnClickListener(this)
|
||||||
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
binding.switchDoubleReader.setOnCheckedChangeListener(this)
|
||||||
|
binding.switchDoubleFoldable.setOnCheckedChangeListener(this)
|
||||||
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
binding.sliderDoubleSensitivity.addOnChangeListener(this)
|
||||||
|
|
||||||
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
viewModel.isBookmarkAdded.observe(viewLifecycleOwner) {
|
||||||
@@ -182,6 +185,12 @@ class ReaderConfigSheet :
|
|||||||
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
viewBinding?.adjustSensitivitySlider(withAnimation = true)
|
||||||
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
findParentCallback(Callback::class.java)?.onDoubleModeChanged(isChecked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
R.id.switch_double_foldable -> {
|
||||||
|
settings.isReaderDoubleOnFoldable = isChecked
|
||||||
|
// Re-evaluate double-page considering foldable state and current manual toggle
|
||||||
|
findParentCallback(Callback::class.java)?.onDoubleModeChanged(settings.isReaderDoubleOnLandscape)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +215,7 @@ class ReaderConfigSheet :
|
|||||||
}
|
}
|
||||||
viewBinding?.run {
|
viewBinding?.run {
|
||||||
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
switchDoubleReader.isEnabled = newMode == ReaderMode.STANDARD || newMode == ReaderMode.REVERSED
|
||||||
|
switchDoubleFoldable.isEnabled = switchDoubleReader.isEnabled
|
||||||
adjustSensitivitySlider(withAnimation = true)
|
adjustSensitivitySlider(withAnimation = true)
|
||||||
}
|
}
|
||||||
if (newMode == mode) {
|
if (newMode == mode) {
|
||||||
@@ -242,12 +252,18 @@ class ReaderConfigSheet :
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
private fun SheetReaderConfigBinding.adjustSensitivitySlider(withAnimation: Boolean) {
|
||||||
val isSliderVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
val isSubOptionsVisible = switchDoubleReader.isEnabled && switchDoubleReader.isChecked
|
||||||
if (isSliderVisible != sliderDoubleSensitivity.isVisible && withAnimation) {
|
val needTransition = withAnimation && (
|
||||||
|
(isSubOptionsVisible != sliderDoubleSensitivity.isVisible) ||
|
||||||
|
(isSubOptionsVisible != textDoubleSensitivity.isVisible) ||
|
||||||
|
(isSubOptionsVisible != switchDoubleFoldable.isVisible)
|
||||||
|
)
|
||||||
|
if (needTransition) {
|
||||||
TransitionManager.beginDelayedTransition(layoutMain)
|
TransitionManager.beginDelayedTransition(layoutMain)
|
||||||
}
|
}
|
||||||
sliderDoubleSensitivity.isVisible = isSliderVisible
|
sliderDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||||
textDoubleSensitivity.isVisible = isSliderVisible
|
textDoubleSensitivity.isVisible = isSubOptionsVisible
|
||||||
|
switchDoubleFoldable.isVisible = isSubOptionsVisible
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
|
|||||||
@@ -25,11 +25,26 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
|
|||||||
readerAdapter = onCreateAdapter()
|
readerAdapter = onCreateAdapter()
|
||||||
|
|
||||||
viewModel.content.observe(viewLifecycleOwner) {
|
viewModel.content.observe(viewLifecycleOwner) {
|
||||||
if (it.state == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
|
// Determine which state to use for restoring position:
|
||||||
onPagesChanged(it.pages, viewModel.getCurrentState())
|
// - content.state: explicitly set state (e.g., after mode switch or chapter change)
|
||||||
} else {
|
// - getCurrentState(): current reading position saved in SavedStateHandle
|
||||||
onPagesChanged(it.pages, it.state)
|
val currentState = viewModel.getCurrentState()
|
||||||
|
val pendingState = when {
|
||||||
|
// If content.state is null and we have pages, use getCurrentState
|
||||||
|
it.state == null
|
||||||
|
&& it.pages.isNotEmpty()
|
||||||
|
&& readerAdapter?.hasItems != true -> currentState
|
||||||
|
|
||||||
|
// use currentState only if it matches the current pages (to avoid the error message)
|
||||||
|
readerAdapter?.hasItems != true
|
||||||
|
&& it.state != currentState
|
||||||
|
&& currentState != null
|
||||||
|
&& it.pages.any { page -> page.chapterId == currentState.chapterId } -> currentState
|
||||||
|
|
||||||
|
// Otherwise, use content.state (normal flow, mode switch, chapter change)
|
||||||
|
else -> it.state
|
||||||
}
|
}
|
||||||
|
onPagesChanged(it.pages, pendingState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class SearchActivity :
|
|||||||
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
|
||||||
supportActionBar?.setSubtitle(R.string.search_results)
|
supportActionBar?.setSubtitle(R.string.search_results)
|
||||||
|
|
||||||
addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind))
|
addMenuProvider(SearchMenuProvider(this, viewModel))
|
||||||
|
|
||||||
viewModel.list.observe(this, adapter)
|
viewModel.list.observe(this, adapter)
|
||||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.nav.router
|
import org.koitharu.kotatsu.core.nav.router
|
||||||
import org.koitharu.kotatsu.search.domain.SearchKind
|
import org.koitharu.kotatsu.search.domain.SearchKind
|
||||||
|
|
||||||
class SearchKindMenuProvider(
|
class SearchMenuProvider(
|
||||||
private val activity: SearchActivity,
|
private val activity: SearchActivity,
|
||||||
private val query: String,
|
private val viewModel: SearchViewModel,
|
||||||
private val kind: SearchKind
|
|
||||||
) : MenuProvider {
|
) : MenuProvider {
|
||||||
|
|
||||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
@@ -22,7 +21,7 @@ class SearchKindMenuProvider(
|
|||||||
override fun onPrepareMenu(menu: Menu) {
|
override fun onPrepareMenu(menu: Menu) {
|
||||||
super.onPrepareMenu(menu)
|
super.onPrepareMenu(menu)
|
||||||
menu.findItem(
|
menu.findItem(
|
||||||
when (kind) {
|
when (viewModel.kind) {
|
||||||
SearchKind.SIMPLE -> R.id.action_kind_simple
|
SearchKind.SIMPLE -> R.id.action_kind_simple
|
||||||
SearchKind.TITLE -> R.id.action_kind_title
|
SearchKind.TITLE -> R.id.action_kind_title
|
||||||
SearchKind.AUTHOR -> R.id.action_kind_author
|
SearchKind.AUTHOR -> R.id.action_kind_author
|
||||||
@@ -32,6 +31,20 @@ class SearchKindMenuProvider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
when (menuItem.itemId) {
|
||||||
|
R.id.action_filter_pinned_only -> {
|
||||||
|
menuItem.isChecked = !menuItem.isChecked
|
||||||
|
viewModel.setPinnedOnly(menuItem.isChecked)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
R.id.action_filter_hide_empty -> {
|
||||||
|
menuItem.isChecked = !menuItem.isChecked
|
||||||
|
viewModel.setHideEmpty(menuItem.isChecked)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val newKind = when (menuItem.itemId) {
|
val newKind = when (menuItem.itemId) {
|
||||||
R.id.action_kind_simple -> SearchKind.SIMPLE
|
R.id.action_kind_simple -> SearchKind.SIMPLE
|
||||||
R.id.action_kind_title -> SearchKind.TITLE
|
R.id.action_kind_title -> SearchKind.TITLE
|
||||||
@@ -39,9 +52,9 @@ class SearchKindMenuProvider(
|
|||||||
R.id.action_kind_tag -> SearchKind.TAG
|
R.id.action_kind_tag -> SearchKind.TAG
|
||||||
else -> return false
|
else -> return false
|
||||||
}
|
}
|
||||||
if (newKind != kind) {
|
if (newKind != viewModel.kind) {
|
||||||
activity.router.openSearch(
|
activity.router.openSearch(
|
||||||
query = query,
|
query = viewModel.query,
|
||||||
kind = newKind,
|
kind = newKind,
|
||||||
)
|
)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
@@ -62,6 +62,8 @@ class SearchViewModel @Inject constructor(
|
|||||||
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
val kind = savedStateHandle.get<SearchKind>(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE
|
||||||
|
|
||||||
private var includeDisabledSources = MutableStateFlow(false)
|
private var includeDisabledSources = MutableStateFlow(false)
|
||||||
|
private var pinnedOnly = MutableStateFlow(false)
|
||||||
|
private var hideEmpty = MutableStateFlow(false)
|
||||||
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
|
private val results = MutableStateFlow<List<SearchResultsListModel>>(emptyList())
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
@@ -70,9 +72,15 @@ class SearchViewModel @Inject constructor(
|
|||||||
results,
|
results,
|
||||||
isLoading.dropWhile { !it },
|
isLoading.dropWhile { !it },
|
||||||
includeDisabledSources,
|
includeDisabledSources,
|
||||||
) { list, loading, includeDisabled ->
|
hideEmpty,
|
||||||
|
) { list, loading, includeDisabled, hideEmptyVal ->
|
||||||
|
val filteredList = if (hideEmptyVal) {
|
||||||
|
list.filter { it.list.isNotEmpty() }
|
||||||
|
} else {
|
||||||
|
list
|
||||||
|
}
|
||||||
when {
|
when {
|
||||||
list.isEmpty() -> listOf(
|
filteredList.isEmpty() -> listOf(
|
||||||
when {
|
when {
|
||||||
loading -> LoadingState
|
loading -> LoadingState
|
||||||
else -> EmptyState(
|
else -> EmptyState(
|
||||||
@@ -84,9 +92,9 @@ class SearchViewModel @Inject constructor(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
loading -> list + LoadingFooter()
|
loading -> filteredList + LoadingFooter()
|
||||||
includeDisabled -> list
|
includeDisabled -> filteredList
|
||||||
else -> list + ButtonFooter(R.string.search_disabled_sources)
|
else -> filteredList + ButtonFooter(R.string.search_disabled_sources)
|
||||||
}
|
}
|
||||||
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||||
|
|
||||||
@@ -114,6 +122,17 @@ class SearchViewModel @Inject constructor(
|
|||||||
doSearch()
|
doSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPinnedOnly(value: Boolean) {
|
||||||
|
if (pinnedOnly.value != value) {
|
||||||
|
pinnedOnly.value = value
|
||||||
|
retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHideEmpty(value: Boolean) {
|
||||||
|
hideEmpty.value = value
|
||||||
|
}
|
||||||
|
|
||||||
fun continueSearch() {
|
fun continueSearch() {
|
||||||
if (includeDisabledSources.value) {
|
if (includeDisabledSources.value) {
|
||||||
return
|
return
|
||||||
@@ -122,8 +141,12 @@ class SearchViewModel @Inject constructor(
|
|||||||
searchJob = launchLoadingJob(Dispatchers.Default) {
|
searchJob = launchLoadingJob(Dispatchers.Default) {
|
||||||
includeDisabledSources.value = true
|
includeDisabledSources.value = true
|
||||||
prevJob?.join()
|
prevJob?.join()
|
||||||
val sources = sourcesRepository.getDisabledSources()
|
val sources = if (pinnedOnly.value) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
sourcesRepository.getDisabledSources()
|
||||||
.sortedByDescending { it.priority() }
|
.sortedByDescending { it.priority() }
|
||||||
|
}
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
sources.map { source ->
|
sources.map { source ->
|
||||||
launch {
|
launch {
|
||||||
@@ -142,7 +165,11 @@ class SearchViewModel @Inject constructor(
|
|||||||
appendResult(searchHistory())
|
appendResult(searchHistory())
|
||||||
appendResult(searchFavorites())
|
appendResult(searchFavorites())
|
||||||
appendResult(searchLocal())
|
appendResult(searchLocal())
|
||||||
val sources = sourcesRepository.getEnabledSources()
|
val sources = if (pinnedOnly.value) {
|
||||||
|
sourcesRepository.getPinnedSources().toList()
|
||||||
|
} else {
|
||||||
|
sourcesRepository.getEnabledSources()
|
||||||
|
}
|
||||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||||
sources.map { source ->
|
sources.map { source ->
|
||||||
launch {
|
launch {
|
||||||
|
|||||||
@@ -130,6 +130,21 @@
|
|||||||
android:textColor="?colorOnSurfaceVariant"
|
android:textColor="?colorOnSurfaceVariant"
|
||||||
app:drawableStartCompat="@drawable/ic_split_horizontal" />
|
app:drawableStartCompat="@drawable/ic_split_horizontal" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switch_double_foldable"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="@dimen/margin_small"
|
||||||
|
android:drawablePadding="?android:listPreferredItemPaddingStart"
|
||||||
|
android:minHeight="?android:listPreferredItemHeightSmall"
|
||||||
|
android:paddingStart="?android:listPreferredItemPaddingStart"
|
||||||
|
android:paddingEnd="?android:listPreferredItemPaddingEnd"
|
||||||
|
android:text="@string/auto_double_foldable"
|
||||||
|
android:textAppearance="@style/TextAppearance.Kotatsu.GridTitle"
|
||||||
|
android:textColor="?colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_double_sensitivity"
|
android:id="@+id/text_double_sensitivity"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -33,6 +33,20 @@
|
|||||||
android:title="@string/genre" />
|
android:title="@string/genre" />
|
||||||
|
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
|
<group android:id="@+id/group_search_filters">
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_filter_pinned_only"
|
||||||
|
android:checkable="true"
|
||||||
|
android:title="@string/pinned_sources_only" />
|
||||||
|
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_filter_hide_empty"
|
||||||
|
android:checkable="true"
|
||||||
|
android:title="@string/hide_empty_sources" />
|
||||||
|
|
||||||
|
</group>
|
||||||
</menu>
|
</menu>
|
||||||
|
|
||||||
</item>
|
</item>
|
||||||
|
|||||||
@@ -879,4 +879,6 @@
|
|||||||
<string name="download_default_directory">Standardni direktorij za preuzimanje manga</string>
|
<string name="download_default_directory">Standardni direktorij za preuzimanje manga</string>
|
||||||
<string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string>
|
<string name="private_app_directory_warning">Ako deinstaliraš aplikaciju, ovaj će se direktorij izbrisati sa svim podacima</string>
|
||||||
<string name="available_pattern">%1$s dostupno</string>
|
<string name="available_pattern">%1$s dostupno</string>
|
||||||
|
<string name="pinned_sources_only">Samo prikvačeni izvori</string>
|
||||||
|
<string name="hide_empty_sources">Sakrij prazne izvore</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -883,4 +883,6 @@
|
|||||||
<string name="download_default_directory">Direktori default untuk mengunduh manga</string>
|
<string name="download_default_directory">Direktori default untuk mengunduh manga</string>
|
||||||
<string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string>
|
<string name="private_app_directory_warning">Direktori ini beserta semua datanya akan dihapus jika Anda menghapus aplikasi</string>
|
||||||
<string name="available_pattern">%1$s tersedia</string>
|
<string name="available_pattern">%1$s tersedia</string>
|
||||||
|
<string name="pinned_sources_only">Hanya sumber yang disematkan</string>
|
||||||
|
<string name="hide_empty_sources">Sembunyikan sumber kosong</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -881,4 +881,7 @@
|
|||||||
<string name="download_default_directory">Diretório padrão onde baixar os mangás</string>
|
<string name="download_default_directory">Diretório padrão onde baixar os mangás</string>
|
||||||
<string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string>
|
<string name="private_app_directory_warning">Esse diretório e todo seu conteúdo será deletado se desinstalar a aplicação</string>
|
||||||
<string name="available_pattern">%1$s disponível</string>
|
<string name="available_pattern">%1$s disponível</string>
|
||||||
|
<string name="hide_empty_sources">Esconder fontes vazias</string>
|
||||||
|
<string name="pinned_sources_only">Apenas fontes fixadas</string>
|
||||||
|
<string name="auto_double_foldable">Duas Páginas Automático em Tela Dobrável</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -884,4 +884,7 @@
|
|||||||
<string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string>
|
<string name="private_app_directory_warning">Uygulamayı kaldırırsanız, bu dizin ve içindeki tüm veriler silinecektir</string>
|
||||||
<string name="available_pattern">%1$s var</string>
|
<string name="available_pattern">%1$s var</string>
|
||||||
<string name="frequency_every_6_hours">6 saatte bir</string>
|
<string name="frequency_every_6_hours">6 saatte bir</string>
|
||||||
|
<string name="pinned_sources_only">Yalnızca sabitlenen kaynaklar</string>
|
||||||
|
<string name="hide_empty_sources">Boş kaynakları gizle</string>
|
||||||
|
<string name="auto_double_foldable">Otomatik İki Sayfa Katlanabilir</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -880,4 +880,11 @@
|
|||||||
<string name="data_removal">Вилучення даних</string>
|
<string name="data_removal">Вилучення даних</string>
|
||||||
<string name="privacy">Конфіденційність</string>
|
<string name="privacy">Конфіденційність</string>
|
||||||
<string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string>
|
<string name="source_broken_warning">Це джерело манги позначено як непрацююче. Деякі функції можуть не працювати</string>
|
||||||
|
<string name="frequency_every_6_hours">Кожні 6 годин</string>
|
||||||
|
<string name="download_default_directory">Каталог за замовчуванням для завантаження манги</string>
|
||||||
|
<string name="private_app_directory_warning">Цей каталог з усіма даними буде видалено, якщо ви видалите програму</string>
|
||||||
|
<string name="available_pattern">Доступно %1$s</string>
|
||||||
|
<string name="auto_double_foldable">Автоматичне двосторінкове розміщення на складному</string>
|
||||||
|
<string name="pinned_sources_only">Тільки закріплені джерела</string>
|
||||||
|
<string name="hide_empty_sources">Приховати порожні джерела</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -866,4 +866,5 @@
|
|||||||
<string name="pull_bottom_no_next">到底了</string>
|
<string name="pull_bottom_no_next">到底了</string>
|
||||||
<string name="enable_pull_gesture_title">启用推拉手势</string>
|
<string name="enable_pull_gesture_title">启用推拉手势</string>
|
||||||
<string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string>
|
<string name="enable_pull_gesture_summary">条漫模式下使用推拉手势切换章节</string>
|
||||||
|
<string name="auto_double_foldable">折叠设备自动双页</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -661,4 +661,5 @@
|
|||||||
<string name="download_new_chapters">下載新的漫畫章節</string>
|
<string name="download_new_chapters">下載新的漫畫章節</string>
|
||||||
<string name="enable_all_sources">啟用所有漫畫來源</string>
|
<string name="enable_all_sources">啟用所有漫畫來源</string>
|
||||||
<string name="all_sources_enabled">所有漫畫來源已啟用</string>
|
<string name="all_sources_enabled">所有漫畫來源已啟用</string>
|
||||||
|
<string name="auto_double_foldable">摺疊設備自動雙頁</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -210,6 +210,8 @@
|
|||||||
<string name="disabled">Disabled</string>
|
<string name="disabled">Disabled</string>
|
||||||
<string name="reset_filter">Reset filter</string>
|
<string name="reset_filter">Reset filter</string>
|
||||||
<string name="enter_name">Enter name</string>
|
<string name="enter_name">Enter name</string>
|
||||||
|
<string name="pinned_sources_only">Pinned sources only</string>
|
||||||
|
<string name="hide_empty_sources">Hide empty sources</string>
|
||||||
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
|
<string name="onboard_text">Select languages which you want to read manga. You can change it later in settings.</string>
|
||||||
<string name="never">Never</string>
|
<string name="never">Never</string>
|
||||||
<string name="only_using_wifi">Only on Wi-Fi</string>
|
<string name="only_using_wifi">Only on Wi-Fi</string>
|
||||||
@@ -579,6 +581,7 @@
|
|||||||
<string name="none">None</string>
|
<string name="none">None</string>
|
||||||
<string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string>
|
<string name="config_reset_confirm">Reset settings to default values? This action cannot be undone.</string>
|
||||||
<string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string>
|
<string name="use_two_pages_landscape">Use two pages layout on landscape orientation (beta)</string>
|
||||||
|
<string name="auto_double_foldable">Auto Two-Page On Foldable</string>
|
||||||
<string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string>
|
<string name="two_page_scroll_sensitivity">Two-Page Scroll Sensitivity</string>
|
||||||
<string name="default_webtoon_zoom_out">Default webtoon zoom out</string>
|
<string name="default_webtoon_zoom_out">Default webtoon zoom out</string>
|
||||||
<string name="fullscreen_mode">Fullscreen mode</string>
|
<string name="fullscreen_mode">Fullscreen mode</string>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ viewpager2 = "1.1.0"
|
|||||||
webkit = "1.14.0"
|
webkit = "1.14.0"
|
||||||
workRuntime = "2.10.5"
|
workRuntime = "2.10.5"
|
||||||
workinspector = "1.2"
|
workinspector = "1.2"
|
||||||
|
window = "1.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
|
acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" }
|
||||||
@@ -115,6 +116,7 @@ okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp
|
|||||||
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }
|
||||||
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
|
ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version.ref = "ssiv" }
|
||||||
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
|
workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" }
|
||||||
|
androidx-window = { module = "androidx.window:window", version.ref = "window" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "gradle" }
|
android-application = { id = "com.android.application", version.ref = "gradle" }
|
||||||
|
|||||||
Reference in New Issue
Block a user