From bcdbbcc5625f4915c0f655d463b4862c3468ca39 Mon Sep 17 00:00:00 2001
From: lhjgege <1079947827@qq.com>
Date: Sat, 15 Feb 2025 20:44:57 +0800
Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=81=9A=E6=BC=AB=E7=94=BB=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD=EF=BC=8C=E4=B8=8D=E5=BD=B1=E5=93=8D=E7=8E=B0=E6=9C=89?=
=?UTF-8?q?=E9=80=BB=E8=BE=91=E6=BC=AB=E7=94=BB=E5=8A=9F=E8=83=BD=20(#4685?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 增加漫画UI
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* 完成加载图片人机验证问题
* 完成加载框
* ...
* ...
* ....
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* 剔除重复章节
* ...
* ....
* ...
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ....
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
* ...
---
app/build.gradle | 3 +
app/src/main/AndroidManifest.xml | 16 +
app/src/main/assets/18PlusList.txt | 2 +-
.../app/base/adapter/RecyclerAdapter.kt | 2 +-
.../java/io/legado/app/constant/PreferKey.kt | 2 +
.../io/legado/app/help/config/AppConfig.kt | 8 +
.../app/help/glide/LegadoGlideModule.kt | 22 +-
.../app/help/glide/OkHttpModeLoaderFactory.kt | 3 +-
.../app/help/glide/OkHttpModelLoader.kt | 9 +-
.../app/help/glide/OkHttpStreamFetcher.kt | 28 +-
.../help/glide/progress/OnProgressListener.kt | 3 +
.../help/glide/progress/ProgressManager.kt | 51 ++
.../glide/progress/ProgressResponseBody.kt | 54 ++
.../io/legado/app/help/http/HttpHelper.kt | 13 +-
.../java/io/legado/app/model/BookCover.kt | 37 +-
.../java/io/legado/app/model/ReadMange.kt | 638 ++++++++++++++++++
.../legado/app/model/recyclerView/MangaVH.kt | 116 ++++
.../app/model/recyclerView/MangeContent.kt | 11 +
.../app/model/recyclerView/ReaderLoading.kt | 8 +
.../legado/app/ui/about/ReadRecordActivity.kt | 10 +-
.../ui/association/FileAssociationActivity.kt | 6 +-
.../association/FileAssociationViewModel.kt | 5 +-
.../app/ui/book/audio/AudioPlayActivity.kt | 4 +-
.../ui/book/import/BaseImportBookActivity.kt | 19 +-
.../book/import/local/ImportBookActivity.kt | 2 +-
.../book/import/remote/RemoteBookActivity.kt | 2 +-
.../app/ui/book/info/BookInfoActivity.kt | 13 +-
.../app/ui/book/manga/MangaViewModel.kt | 240 +++++++
.../app/ui/book/manga/ReadMangaActivity.kt | 483 +++++++++++++
.../manga/rv/GestureDetectorWithLongTap.kt | 64 ++
.../app/ui/book/manga/rv/MangaAdapter.kt | 230 +++++++
.../app/ui/book/manga/rv/WebtoonFrame.kt | 85 +++
.../ui/book/manga/rv/WebtoonRecyclerView.kt | 390 +++++++++++
.../io/legado/app/ui/book/read/MangaMenu.kt | 236 +++++++
.../bookshelf/style1/books/BooksFragment.kt | 4 +-
.../bookshelf/style2/BookshelfFragment2.kt | 3 +-
.../legado/app/ui/widget/ReaderInfoBarView.kt | 205 ++++++
.../app/ui/widget/recycler/LoadMoreView.kt | 10 +-
.../io/legado/app/utils/ActivityExtensions.kt | 6 +
.../io/legado/app/utils/ContextExtensions.kt | 27 +-
.../io/legado/app/utils/FragmentExtensions.kt | 16 +
.../app/utils/RecyclerViewExtensions.kt | 28 +
app/src/main/res/layout/activity_mange.xml | 93 +++
.../main/res/layout/book_comic_loading_rv.xml | 16 +
app/src/main/res/layout/book_comic_rv.xml | 64 ++
app/src/main/res/layout/view_manga_menu.xml | 73 ++
app/src/main/res/menu/book_manga.xml | 21 +
app/src/main/res/values-es-rES/strings.xml | 3 +
app/src/main/res/values-ja-rJP/strings.xml | 3 +
app/src/main/res/values-pt-rBR/strings.xml | 3 +
app/src/main/res/values-vi/strings.xml | 3 +
app/src/main/res/values-zh-rHK/strings.xml | 3 +
app/src/main/res/values-zh-rTW/strings.xml | 3 +
app/src/main/res/values-zh/strings.xml | 3 +
app/src/main/res/values/colors.xml | 2 +
app/src/main/res/values/strings.xml | 3 +
app/src/main/res/xml/pref_config_other.xml | 12 +
gradle/libs.versions.toml | 3 +
58 files changed, 3373 insertions(+), 49 deletions(-)
create mode 100644 app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt
create mode 100644 app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt
create mode 100644 app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt
create mode 100644 app/src/main/java/io/legado/app/model/ReadMange.kt
create mode 100644 app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt
create mode 100644 app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt
create mode 100644 app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt
create mode 100644 app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
create mode 100644 app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt
create mode 100644 app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt
create mode 100644 app/src/main/res/layout/activity_mange.xml
create mode 100644 app/src/main/res/layout/book_comic_loading_rv.xml
create mode 100644 app/src/main/res/layout/book_comic_rv.xml
create mode 100644 app/src/main/res/layout/view_manga_menu.xml
create mode 100644 app/src/main/res/menu/book_manga.xml
diff --git a/app/build.gradle b/app/build.gradle
index e9db05bed..7e18c25b4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -238,6 +238,7 @@ dependencies {
//Glide
implementation(libs.glide.glide)
+ implementation(libs.glide.okhttp)
ksp(libs.glide.ksp)
//Svg
@@ -279,6 +280,8 @@ dependencies {
implementation platform(libs.firebase.bom)
implementation libs.firebase.analytics
implementation libs.firebase.perf
+ implementation libs.zoom.imageview
+ implementation libs.glide.recyclerview
//LeakCanary, 内存泄露检测
//debugImplementation('com.squareup.leakcanary:leakcanary-android:2.7')
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 07a76c223..ecb309347 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -24,6 +24,7 @@
+
+
+
+
+
+
+
+
+
(protected val context: Co
companion object {
private const val TYPE_HEADER_VIEW = Int.MIN_VALUE
- private const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999
+ const val TYPE_FOOTER_VIEW = Int.MAX_VALUE - 999
private val handler by lazy { buildMainHandler() }
}
diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt
index bcf5fb8fa..b5960674c 100644
--- a/app/src/main/java/io/legado/app/constant/PreferKey.kt
+++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt
@@ -153,6 +153,8 @@ object PreferKey {
const val streamReadAloudAudio = "streamReadAloudAudio"
const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls"
const val readAloudByMediaButton = "readAloudByMediaButton"
+ const val showMangaUi="showMangaUi"
+ const val disableMangaScaling="disableMangaScaling"
const val cPrimary = "colorPrimary"
const val cAccent = "colorAccent"
diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt
index 2afadab09..3d25d5d22 100644
--- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt
+++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt
@@ -604,5 +604,13 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
appCtx.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
}
}
+
+ //跳转到漫画界面不使用富文本模式
+ val showMangaUi: Boolean
+ get() = appCtx.getPrefBoolean(PreferKey.showMangaUi, true)
+
+ //禁用漫画缩放
+ val disableMangaScaling: Boolean
+ get() = appCtx.getPrefBoolean(PreferKey.disableMangaScaling, true)
}
diff --git a/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt b/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt
index bed786283..dde9cba19 100644
--- a/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt
+++ b/app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt
@@ -2,10 +2,16 @@ package io.legado.app.help.glide
import android.content.Context
import com.bumptech.glide.Glide
+import com.bumptech.glide.GlideBuilder
import com.bumptech.glide.Registry
import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.load.DecodeFormat
+import com.bumptech.glide.load.engine.bitmap_recycle.LruBitmapPool
+import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory
+import com.bumptech.glide.load.engine.cache.LruResourceCache
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.module.AppGlideModule
+import com.bumptech.glide.request.RequestOptions
import java.io.InputStream
@@ -13,7 +19,6 @@ import java.io.InputStream
@GlideModule
class LegadoGlideModule : AppGlideModule() {
-
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.replace(
GlideUrl::class.java,
@@ -22,4 +27,19 @@ class LegadoGlideModule : AppGlideModule() {
)
}
+ override fun applyOptions(context: Context, builder: GlideBuilder) {
+ super.applyOptions(context, builder)
+ builder.setMemoryCache(LruResourceCache(1024 * 1024 * 500))
+ .setBitmapPool(LruBitmapPool(1024 * 1024 * 200))
+ .setDiskCache(
+ InternalCacheDiskCacheFactory(
+ context,
+ 1024 * 1024 * 1000
+ )
+ )
+ .setDefaultRequestOptions {
+ RequestOptions().format(DecodeFormat.PREFER_RGB_565)
+ .encodeQuality(90)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt
index 7b99fb0fb..6be342699 100644
--- a/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt
+++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt
@@ -4,10 +4,11 @@ import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.ModelLoaderFactory
import com.bumptech.glide.load.model.MultiModelLoaderFactory
+import okhttp3.Call
import java.io.InputStream
-object OkHttpModeLoaderFactory : ModelLoaderFactory {
+object OkHttpModeLoaderFactory: ModelLoaderFactory {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader {
return OkHttpModelLoader
diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt
index e7dc687f0..1f813a7c1 100644
--- a/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt
+++ b/app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt
@@ -6,26 +6,29 @@ import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.ModelLoader
import io.legado.app.model.analyzeRule.AnalyzeUrl
import io.legado.app.utils.isAbsUrl
-
import java.io.InputStream
object OkHttpModelLoader : ModelLoader {
val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false)
val sourceOriginOption = Option.memory("sourceOrigin")
+ val mangaOption = Option.memory("manga",false)
override fun buildLoadData(
model: GlideUrl,
width: Int,
height: Int,
- options: Options
+ options: Options,
): ModelLoader.LoadData {
val cacheKey = model.toString()
var modelWithHeader = model
if (cacheKey.isAbsUrl()) {
modelWithHeader = AnalyzeUrl(cacheKey).getGlideUrl()
}
- return ModelLoader.LoadData(modelWithHeader, OkHttpStreamFetcher(modelWithHeader, options))
+ return ModelLoader.LoadData(
+ modelWithHeader,
+ OkHttpStreamFetcher(modelWithHeader, options)
+ )
}
override fun handles(model: GlideUrl): Boolean {
diff --git a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt
index d7409b0fe..5e9895961 100644
--- a/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt
+++ b/app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt
@@ -14,6 +14,7 @@ import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.source.SourceHelp
+import io.legado.app.model.ReadMange
import io.legado.app.utils.ImageUtils
import io.legado.app.utils.isWifiConnect
import okhttp3.Call
@@ -26,7 +27,10 @@ import java.io.IOException
import java.io.InputStream
-class OkHttpStreamFetcher(private val url: GlideUrl, private val options: Options) :
+class OkHttpStreamFetcher(
+ private val url: GlideUrl,
+ private val options: Options,
+) :
DataFetcher, okhttp3.Callback {
private var stream: InputStream? = null
private var responseBody: ResponseBody? = null
@@ -95,15 +99,31 @@ class OkHttpStreamFetcher(private val url: GlideUrl, private val options: Option
override fun onResponse(call: Call, response: Response) {
responseBody = response.body
- if (response.isSuccessful) {
- val decodeResult = ImageUtils.decode(
+ val manga = options.get(OkHttpModelLoader.mangaOption) == true
+ val decodeResult = if (manga) {
+ ImageUtils.decode(
+ url.toStringUrl(),
+ responseBody!!.byteStream().readBytes(),
+ isCover = false,
+ source,
+ ReadMange.book
+ ).let {
+ ByteArrayInputStream(it)
+ }
+ } else {
+ ImageUtils.decode(
url.toStringUrl(), responseBody!!.byteStream(),
isCover = true, source
)
+ }
+ if (response.isSuccessful) {
+
if (decodeResult == null) {
callback?.onLoadFailed(NoStackTraceException("封面二次解密失败"))
} else {
- val contentLength: Long = if (decodeResult is ByteArrayInputStream) decodeResult.available().toLong() else Preconditions.checkNotNull(responseBody).contentLength()
+ val contentLength: Long =
+ if (decodeResult is ByteArrayInputStream) decodeResult.available()
+ .toLong() else Preconditions.checkNotNull(responseBody).contentLength()
stream = ContentLengthInputStream.obtain(decodeResult, contentLength)
callback?.onDataReady(stream)
}
diff --git a/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt b/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt
new file mode 100644
index 000000000..19561c75b
--- /dev/null
+++ b/app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt
@@ -0,0 +1,3 @@
+package io.legado.app.help.glide.progress
+
+typealias OnProgressListener = ((isComplete: Boolean, percentage: Int, bytesRead: Long, totalBytes: Long) -> Unit)?
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt b/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt
new file mode 100644
index 000000000..0cebbc5f0
--- /dev/null
+++ b/app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt
@@ -0,0 +1,51 @@
+package io.legado.app.help.glide.progress
+
+import android.text.TextUtils
+import io.legado.app.utils.runOnUI
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * 进度监听器管理类
+ * 加入图片加载进度监听,加入Https支持
+ */
+object ProgressManager {
+ private val listenersMap = ConcurrentHashMap()
+
+ val LISTENER = object : ProgressResponseBody.InternalProgressListener {
+ override fun onProgress(url: String, bytesRead: Long, totalBytes: Long) {
+ getProgressListener(url)?.let {
+ var percentage = (bytesRead * 1f / totalBytes * 100f).toInt()
+ var isComplete = percentage >= 100
+ if (percentage <= -100) {
+ percentage = 0
+ isComplete = true
+ }
+ runOnUI {
+ it.invoke(isComplete, percentage, bytesRead, totalBytes)
+ }
+ if (isComplete) {
+ removeListener(url)
+ }
+ }
+ }
+ }
+
+ fun addListener(url: String, listener: OnProgressListener) {
+ if (!TextUtils.isEmpty(url) && listener != null) {
+ listenersMap[url] = listener
+ listener.invoke(false, 1, 0, 0)
+ }
+ }
+
+ fun removeListener(url: String) {
+ if (!TextUtils.isEmpty(url)) {
+ listenersMap.remove(url)
+ }
+ }
+
+ fun getProgressListener(url: String?): OnProgressListener {
+ return if (TextUtils.isEmpty(url) || listenersMap.size == 0) {
+ null
+ } else listenersMap[url]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt b/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt
new file mode 100644
index 000000000..9a8158a9a
--- /dev/null
+++ b/app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt
@@ -0,0 +1,54 @@
+package io.legado.app.help.glide.progress
+
+import android.os.Handler
+import android.os.Looper
+import okhttp3.MediaType
+import okhttp3.ResponseBody
+import okio.*
+import java.io.IOException
+import kotlin.jvm.Throws
+
+class ProgressResponseBody internal constructor(private val url: String, private val internalProgressListener: InternalProgressListener?, private val responseBody: ResponseBody) : ResponseBody() {
+ private var bufferedSource: BufferedSource? = null
+ override fun contentType(): MediaType? {
+ return responseBody.contentType()
+ }
+
+ override fun contentLength(): Long {
+ return responseBody.contentLength()
+ }
+
+ override fun source(): BufferedSource {
+ if (bufferedSource == null) {
+ bufferedSource = source(responseBody.source()).buffer()
+ }
+ return bufferedSource!!
+ }
+
+ private fun source(source: Source): Source {
+ return object : ForwardingSource(source) {
+ var totalBytesRead: Long = 0
+ var lastTotalBytesRead: Long = 0
+
+ @Throws(IOException::class)
+ override fun read(sink: Buffer, byteCount: Long): Long {
+ val bytesRead = super.read(sink, byteCount)
+ totalBytesRead += if (bytesRead == -1L) 0 else bytesRead
+ if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) {
+ lastTotalBytesRead = totalBytesRead
+ mainThreadHandler.post { internalProgressListener.onProgress(url, totalBytesRead, contentLength()) }
+ }
+ return bytesRead
+ }
+ }
+ }
+
+ interface InternalProgressListener {
+ fun onProgress(url: String, bytesRead: Long, totalBytes: Long)
+ }
+
+ companion object {
+ private val mainThreadHandler = Handler(Looper.getMainLooper())
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt
index df8d1b217..67e30b7d3 100644
--- a/app/src/main/java/io/legado/app/help/http/HttpHelper.kt
+++ b/app/src/main/java/io/legado/app/help/http/HttpHelper.kt
@@ -3,6 +3,9 @@ package io.legado.app.help.http
import io.legado.app.constant.AppConst
import io.legado.app.help.CacheManager
import io.legado.app.help.config.AppConfig
+import io.legado.app.help.glide.progress.ProgressManager
+import io.legado.app.help.glide.progress.ProgressManager.LISTENER
+import io.legado.app.help.glide.progress.ProgressResponseBody
import io.legado.app.help.http.CookieManager.cookieJarHeader
import io.legado.app.utils.NetworkUtils
import okhttp3.ConnectionSpec
@@ -93,7 +96,15 @@ val okHttpClient: OkHttpClient by lazy {
if (enableCookieJar) {
CookieManager.saveResponse(networkResponse)
}
- networkResponse
+ networkResponse.newBuilder()
+ .body(
+ ProgressResponseBody(
+ request.url.toString(),
+ LISTENER,
+ networkResponse.body!!
+ )
+ )
+ .build()
}
if (AppConfig.isCronet) {
if (Cronet.loader?.install() == true) {
diff --git a/app/src/main/java/io/legado/app/model/BookCover.kt b/app/src/main/java/io/legado/app/model/BookCover.kt
index 477fb8de2..a458c0de7 100644
--- a/app/src/main/java/io/legado/app/model/BookCover.kt
+++ b/app/src/main/java/io/legado/app/model/BookCover.kt
@@ -6,9 +6,11 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.Keep
import com.bumptech.glide.RequestBuilder
+import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.CenterCrop
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
import com.bumptech.glide.request.RequestOptions
+import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
import io.legado.app.R
import io.legado.app.constant.PreferKey
import io.legado.app.data.entities.BaseSource
@@ -21,7 +23,11 @@ import io.legado.app.help.glide.ImageLoader
import io.legado.app.help.glide.OkHttpModelLoader
import io.legado.app.model.analyzeRule.AnalyzeRule
import io.legado.app.model.analyzeRule.AnalyzeUrl
-import io.legado.app.utils.*
+import io.legado.app.utils.BitmapUtils
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.getPrefBoolean
+import io.legado.app.utils.getPrefString
import splitties.init.appCtx
@Keep
@@ -71,7 +77,7 @@ object BookCover {
context: Context,
path: String?,
loadOnlyWifi: Boolean = false,
- sourceOrigin: String? = null
+ sourceOrigin: String? = null,
): RequestBuilder {
if (AppConfig.useDefaultCover) {
return ImageLoader.load(context, defaultDrawable)
@@ -88,6 +94,31 @@ object BookCover {
.centerCrop()
}
+ /**
+ * 加载漫画图片
+ */
+
+ fun loadManga(
+ context: Context,
+ path: String?,
+ loadOnlyWifi: Boolean = false,
+ sourceOrigin: String? = null,
+ manga: Boolean = false,
+ useDefaultCover: Drawable? = null,
+ ): RequestBuilder {
+ var options = RequestOptions().set(OkHttpModelLoader.loadOnlyWifiOption, loadOnlyWifi)
+ .set(OkHttpModelLoader.mangaOption, manga)
+ if (sourceOrigin != null) {
+ options = options.set(OkHttpModelLoader.sourceOriginOption, sourceOrigin)
+ }
+ return ImageLoader.load(context, path)
+ .apply(options)
+ .override(context.resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
+ .placeholder(useDefaultCover)
+ .error(useDefaultCover)
+ .diskCacheStrategy(DiskCacheStrategy.ALL)
+ }
+
/**
* 加载模糊封面
*/
@@ -95,7 +126,7 @@ object BookCover {
context: Context,
path: String?,
loadOnlyWifi: Boolean = false,
- sourceOrigin: String? = null
+ sourceOrigin: String? = null,
): RequestBuilder {
val loadBlur = ImageLoader.load(context, defaultDrawable)
.transform(BlurTransformation(25), CenterCrop())
diff --git a/app/src/main/java/io/legado/app/model/ReadMange.kt b/app/src/main/java/io/legado/app/model/ReadMange.kt
new file mode 100644
index 000000000..a87773dd6
--- /dev/null
+++ b/app/src/main/java/io/legado/app/model/ReadMange.kt
@@ -0,0 +1,638 @@
+package io.legado.app.model
+
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.AppPattern
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookProgress
+import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.ReadRecord
+import io.legado.app.help.AppWebDav
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.readSimulating
+import io.legado.app.help.book.simulatedTotalChapterNum
+import io.legado.app.help.book.update
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.globalExecutor
+import io.legado.app.model.recyclerView.MangeContent
+import io.legado.app.model.recyclerView.ReaderLoading
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.mapIndexed
+import io.legado.app.utils.runOnUI
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlin.math.min
+
+@Suppress("MemberVisibilityCanBePrivate")
+object ReadMange : CoroutineScope by MainScope() {
+ var inBookshelf = false
+ var tocChanged = false
+ var chapterChanged = false
+ var book: Book? = null
+ val executor = globalExecutor
+ var durChapterPagePos = 0 //章节位置
+ var durChapterPageCount = 0//总章节
+ var durChapterCount = 0
+ var durChapterPos = 0
+ var bookSource: BookSource? = null
+ var chapterTitle: String = ""
+ var readStartTime: Long = System.currentTimeMillis()
+ private val readRecord = ReadRecord()
+ private val loadingChapters = arrayListOf()
+ var simulatedChapterSize = 0
+ var mCallback: Callback? = null
+ var mFirstLoading = false
+ var gameOver = false
+ var mTopChapter: BookChapter? = null
+ var preDownloadTask: Job? = null
+ val downloadedChapters = hashSetOf()
+ val downloadFailChapters = hashMapOf()
+ private val downloadLoadingChapters = arrayListOf()
+ var isMangaMode = false
+ val downloadScope = CoroutineScope(SupervisorJob() + IO)
+
+ fun saveRead(pageChanged: Boolean = false) {
+ executor.execute {
+ val book = ReadMange.book ?: return@execute
+ book.lastCheckCount = 0
+ book.durChapterTime = System.currentTimeMillis()
+ val chapterChanged = book.durChapterIndex != durChapterPagePos
+ book.durChapterIndex = durChapterPagePos
+ book.durChapterPos = durChapterPos
+ if (!pageChanged || chapterChanged) {
+ appDb.bookChapterDao.getChapter(book.bookUrl, durChapterPagePos)?.let {
+ book.durChapterTitle = it.getDisplayTitle(
+ ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
+ book.getUseReplaceRule()
+ )
+ }
+ }
+ appDb.bookDao.update(book)
+ }
+ }
+
+ fun upData(book: Book) {
+ ReadMange.book = book
+ isMangaMode = true
+ durChapterPageCount = appDb.bookChapterDao.getChapterCount(book.bookUrl)
+ simulatedChapterSize = if (book.readSimulating()) {
+ book.simulatedTotalChapterNum()
+ } else {
+ durChapterPageCount
+ }
+
+ if (durChapterPagePos != book.durChapterIndex || tocChanged) {
+ durChapterPagePos = book.durChapterIndex
+ durChapterPos = book.durChapterPos
+ }
+ upWebBook(book)
+
+ synchronized(this) {
+ loadingChapters.clear()
+ }
+ }
+
+ fun resetData(book: Book) {
+ ReadMange.book = book
+ isMangaMode = true
+ readRecord.bookName = book.name
+ readRecord.readTime = appDb.readRecordDao.getReadTime(book.name) ?: 0
+ durChapterPageCount = appDb.bookChapterDao.getChapterCount(book.bookUrl)
+ simulatedChapterSize = if (book.readSimulating()) {
+ book.simulatedTotalChapterNum()
+ } else {
+ durChapterPageCount
+ }
+ durChapterPagePos = book.durChapterIndex
+ durChapterPos = book.durChapterPos
+ upWebBook(book)
+ synchronized(this) {
+ loadingChapters.clear()
+ }
+ }
+
+ fun upWebBook(book: Book) {
+ appDb.bookSourceDao.getBookSource(book.origin)?.let {
+ bookSource = it
+ } ?: let {
+ bookSource = null
+ }
+ }
+
+
+ //每次切换章节更新阅读记录
+ fun upReadTime() {
+ executor.execute {
+ if (!AppConfig.enableReadRecord) {
+ return@execute
+ }
+ readRecord.readTime = readRecord.readTime + System.currentTimeMillis() - readStartTime
+ readStartTime = System.currentTimeMillis()
+ readRecord.lastRead = System.currentTimeMillis()
+ appDb.readRecordDao.insert(readRecord)
+ }
+ }
+
+ private fun addLoading(index: Int): Boolean {
+ synchronized(this) {
+ if (loadingChapters.contains(index)) return false
+ loadingChapters.add(index)
+ return true
+ }
+ }
+
+ private fun addDownloadLoading(index: Int): Boolean {
+ synchronized(this) {
+ if (downloadLoadingChapters.contains(index)) return false
+ downloadLoadingChapters.add(index)
+ return true
+ }
+ }
+
+
+ fun removeDownloadLoading(index: Int) {
+ synchronized(this) {
+ downloadLoadingChapters.remove(index)
+ }
+ }
+
+ fun removeLoading(index: Int) {
+ synchronized(this) {
+ loadingChapters.remove(index)
+ }
+ }
+
+ fun loading(index: Int): Boolean {
+ synchronized(this) {
+ return loadingChapters.contains(index)
+ }
+ }
+
+
+ /**
+ * 获取正文
+ */
+ private suspend fun getContent(
+ scope: CoroutineScope,
+ chapter: BookChapter,
+ ) {
+ val book = ReadMange.book ?: return removeLoading(chapter.index)
+ val bookSource = ReadMange.bookSource
+ if (bookSource != null) {
+ getContent(bookSource, scope, chapter, book)
+ }
+ }
+
+
+ fun loadContent() {
+ loadContent(durChapterPagePos)
+ }
+
+ fun loadContent(
+ index: Int,
+ ) {
+ if (addLoading(index)) {
+ Coroutine.async {
+ val book = ReadMange.book!!
+ appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter ->
+ getContent(
+ downloadScope,
+ chapter,
+ )
+ } ?: removeLoading(index)
+ }.onError {
+ removeLoading(index)
+ AppLog.put("加载正文出错\n${it.localizedMessage}")
+ }
+ }
+
+ }
+
+
+ /**
+ * 注册回调
+ */
+ fun register(cb: Callback) {
+ mCallback = cb
+ }
+
+ /**
+ * 取消注册回调
+ */
+ fun unregister() {
+ inBookshelf = false
+ tocChanged = false
+ chapterChanged = false
+ book = null
+ durChapterPagePos = 0
+ isMangaMode = false
+ durChapterPageCount = 0
+ durChapterPos = 0
+ durChapterCount = 0
+ bookSource = null
+ chapterTitle = ""
+ loadingChapters.clear()
+ simulatedChapterSize = 0
+ mCallback = null
+ mFirstLoading = false
+ gameOver = false
+ mTopChapter = null
+ preDownloadTask?.cancel()
+ preDownloadTask = null
+ downloadedChapters.clear()
+ downloadFailChapters.clear()
+ downloadLoadingChapters.clear()
+ executor.asCoroutineDispatcher().cancelChildren()
+ downloadScope.coroutineContext.cancelChildren()
+ coroutineContext.cancelChildren()
+ }
+
+ /**
+ * 内容加载完成
+ */
+ suspend fun contentLoadFinish(
+ chapter: BookChapter,
+ content: String,
+ book: Book
+ ) {
+ if (mTopChapter != null && mTopChapter!!.title != chapterTitle && BookHelp.hasContent(
+ book,
+ mTopChapter!!
+ )
+ ) {
+ BookHelp.delContent(book, chapter)
+ }
+ mTopChapter = chapter
+ chapterTitle = chapter.title
+ if (chapter.index !in durChapterPagePos - 1..durChapterPagePos + 1) {
+ return
+ }
+ if (content.isNotEmpty()) {
+ val list = flow {
+ val matcher = AppPattern.imgPattern.matcher(content)
+ while (matcher.find()) {
+ val src = matcher.group(1) ?: continue
+ val mSrc = NetworkUtils.getAbsoluteURL(chapter.url, src)
+ emit(mSrc)
+ }
+ }.distinctUntilChangedBy {
+ it
+ }.mapIndexed { index, src ->
+ MangeContent(
+ mChapterPageCount = durChapterPageCount,
+ mChapterPagePos = durChapterPagePos,
+ mChapterNextPagePos = durChapterPagePos.plus(1),
+ mImageUrl = src,
+ mDurChapterPos = index.plus(1)
+ )
+ }.toList().apply {
+ this.forEach {
+ it.mDurChapterCount = this.size
+ }
+ }
+ val contentList = mutableListOf()
+ contentList.add(
+ ReaderLoading(
+ durChapterPagePos,
+ "阅读 ${chapter.title}",
+ mNextChapterIndex = durChapterPagePos.plus(1)
+ )
+ )
+ contentList.addAll(list)
+ durChapterCount = contentList.size
+ contentList.add(
+ ReaderLoading(
+ durChapterPagePos,
+ "已读完 ${chapter.title}",
+ mNextChapterIndex = durChapterPagePos.plus(1)
+ )
+ )
+ runOnUI {
+ mCallback?.loadContentFinish(contentList)
+ }
+ }
+ }
+
+ /**
+ * 加载下一章
+ */
+ fun moveToNextChapter(index: Int) {
+
+ if (loading(index)) {
+ return
+ }
+
+ if (index > durChapterPageCount - 1) {
+ upToc(index)
+ return
+ }
+ if (durChapterPagePos < simulatedChapterSize - 1) {
+ durChapterPos = 0
+ durChapterPagePos = index
+ saveRead()
+ loadContent(durChapterPagePos)
+ AppLog.putDebug("moveToNextChapter-curPageChanged()")
+ curPageChanged()
+ } else {
+ AppLog.putDebug("跳转下一章失败,没有下一章")
+ }
+ }
+
+ fun curPageChanged() {
+ upReadTime()
+ preDownload()
+ }
+
+ @Synchronized
+ fun upToc(index: Int) {
+ val bookSource = bookSource ?: return
+ val book = book ?: return
+ if (!book.canUpdate) return
+ if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
+ book.lastCheckTime = System.currentTimeMillis()
+ WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
+ if (book.bookUrl == ReadMange.book?.bookUrl
+ && cList.size > durChapterPageCount
+ ) {
+ appDb.bookChapterDao.delByBook(book.bookUrl)
+ appDb.bookChapterDao.insert(*cList.toTypedArray())
+ saveRead()
+ durChapterPos = 0
+ durChapterPagePos = index
+ durChapterPageCount = cList.size
+ simulatedChapterSize = book.simulatedTotalChapterNum()
+ loadContent(durChapterPagePos)
+ } else {
+ durChapterPagePos = durChapterPagePos.minus(1)
+ saveRead()
+ gameOver = true
+ runOnUI {
+ mCallback?.noData()
+ }
+ }
+ }
+ }
+
+ private suspend fun getContent(
+ bookSource: BookSource,
+ scope: CoroutineScope,
+ chapter: BookChapter,
+ book: Book,
+ ) {
+ if (BookHelp.hasContent(book, chapter)) {
+ BookHelp.getContent(book, chapter)?.apply {
+ contentLoadFinish(chapter, this, book)
+ runOnUI {
+ mCallback?.loadComplete()
+ }
+ if (durChapterPagePos >= durChapterPageCount.minus(1)) {
+ gameOver = true
+ runOnUI {
+ mCallback?.noData()
+ }
+ }
+ } ?: downloadNetworkContent(bookSource, scope, chapter, book, success = {
+ contentLoadFinish(chapter, it, book)
+ runOnUI {
+ mCallback?.loadComplete()
+ }
+ }, error = {
+ removeLoading(chapter.index)
+ runOnUI {
+ mCallback?.loadFail("加载内容失败")
+ }
+ })
+ } else {
+ downloadNetworkContent(bookSource, scope, chapter, book, success = {
+ contentLoadFinish(chapter, it, book)
+ runOnUI {
+ mCallback?.loadComplete()
+ }
+ }, error = {
+ removeLoading(chapter.index)
+ runOnUI {
+ mCallback?.loadFail("加载内容失败")
+ }
+ })
+ }
+ }
+
+ private fun downloadNetworkContent(
+ bookSource: BookSource,
+ scope: CoroutineScope,
+ chapter: BookChapter,
+ book: Book,
+ success: suspend (String) -> Unit = {},
+ error: suspend () -> Unit = {},
+ ) {
+ WebBook.getContent(
+ scope,
+ bookSource,
+ book,
+ chapter,
+ start = CoroutineStart.LAZY,
+ executeContext = IO,
+ needSave = true
+ ).onSuccess { content ->
+ success.invoke(content)
+ }.onError {
+ error.invoke()
+ }.start()
+ }
+
+ private fun preDownload() {
+ if (book?.isLocal == true) return
+ executor.execute {
+ if (AppConfig.preDownloadNum < 2) {
+ return@execute
+ }
+ preDownloadTask?.cancel()
+ preDownloadTask = launch(IO) {
+ //预下载
+ launch {
+ val maxChapterIndex =
+ min(durChapterPagePos + AppConfig.preDownloadNum, durChapterPageCount)
+ for (i in durChapterPagePos.plus(2)..maxChapterIndex) {
+ if (downloadedChapters.contains(i)) continue
+ if ((downloadFailChapters[i] ?: 0) >= 3) continue
+ downloadIndex(i)
+ }
+ }
+ launch {
+ val minChapterIndex = durChapterPagePos - min(5, AppConfig.preDownloadNum)
+ for (i in durChapterPagePos.minus(2) downTo minChapterIndex) {
+ if (downloadedChapters.contains(i)) continue
+ if ((downloadFailChapters[i] ?: 0) >= 3) continue
+ downloadIndex(i)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun downloadIndex(index: Int) {
+ if (index < 0) return
+ if (index > durChapterPageCount - 1) {
+ upToc()
+ return
+ }
+ val book = book ?: return
+ if (addDownloadLoading(index)) {
+ try {
+ appDb.bookChapterDao.getChapter(book.bookUrl, index)?.let { chapter ->
+ if (BookHelp.hasContent(book, chapter)) {
+ removeDownloadLoading(chapter.index)
+ downloadedChapters.add(chapter.index)
+ } else {
+ delay(1000)
+ downloadNetworkContent(
+ bookSource!!,
+ downloadScope,
+ chapter,
+ book,
+ success = {
+ downloadedChapters.add(chapter.index)
+ downloadFailChapters.remove(chapter.index)
+ },
+ error = {
+ downloadFailChapters[chapter.index] =
+ (downloadFailChapters[chapter.index] ?: 0) + 1
+ removeDownloadLoading(chapter.index)
+ })
+ }
+ } ?: removeDownloadLoading(index)
+ } catch (e: Exception) {
+ removeLoading(index)
+ }
+ }
+ }
+
+ @Synchronized
+ fun upToc() {
+ val bookSource = bookSource ?: return
+ val book = book ?: return
+ if (!book.canUpdate) return
+ if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
+ book.lastCheckTime = System.currentTimeMillis()
+ WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
+ if (book.bookUrl == ReadMange.book?.bookUrl
+ && cList.size > durChapterPageCount
+ ) {
+ appDb.bookChapterDao.delByBook(book.bookUrl)
+ appDb.bookChapterDao.insert(*cList.toTypedArray())
+ saveRead()
+ durChapterPageCount = cList.size
+ simulatedChapterSize = book.simulatedTotalChapterNum()
+ }
+ }
+ }
+
+ fun uploadProgress(successAction: (() -> Unit)? = null) {
+ book?.let {
+ launch(IO) {
+ AppWebDav.uploadBookProgress(it)
+ ensureActive()
+ it.update()
+ successAction?.invoke()
+ }
+ }
+ }
+
+
+ /**
+ * 同步阅读进度
+ * 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作
+ */
+ fun syncProgress(
+ newProgressAction: ((progress: BookProgress) -> Unit)? = null,
+ uploadSuccessAction: (() -> Unit)? = null,
+ syncSuccessAction: (() -> Unit)? = null
+ ) {
+ if (!AppConfig.syncBookProgress) return
+ book?.let {
+ Coroutine.async {
+ AppWebDav.getBookProgress(it)
+ }.onError {
+ AppLog.put("拉取阅读进度失败", it)
+ }.onSuccess { progress ->
+ if (progress == null || progress.durChapterIndex < it.durChapterIndex ||
+ (progress.durChapterIndex == it.durChapterIndex
+ && progress.durChapterPos < it.durChapterPos)
+ ) {
+ // 服务器没有进度或者进度比服务器快,上传现有进度
+ Coroutine.async {
+ AppWebDav.uploadBookProgress(BookProgress(it), uploadSuccessAction)
+ it.update()
+ }
+ } else if (progress.durChapterIndex > it.durChapterIndex ||
+ progress.durChapterPos > it.durChapterPos
+ ) {
+ // 进度比服务器慢,执行传入动作
+ newProgressAction?.invoke(progress)
+ } else {
+ syncSuccessAction?.invoke()
+ }
+ }
+ }
+ }
+
+ fun setProgress(progress: BookProgress) {
+ if (progress.durChapterIndex < durChapterPageCount &&
+ (durChapterPagePos != progress.durChapterIndex
+ || durChapterPos != progress.durChapterPos)
+ ) {
+ chapterChanged = true
+ if (progress.durChapterIndex == durChapterPagePos) {
+ durChapterPos = progress.durChapterPos
+ mCallback?.adjustmentProgress()
+ } else {
+ durChapterPagePos = progress.durChapterIndex
+ durChapterPos = progress.durChapterPos
+ if (addLoading(durChapterPagePos)) {
+ loadContent(durChapterPagePos)
+ } else {
+ Coroutine.async {
+ val book = ReadMange.book!!
+ appDb.bookChapterDao.getChapter(book.bookUrl, durChapterPagePos)
+ ?.let { chapter ->
+ getContent(
+ downloadScope,
+ chapter,
+ )
+ }
+ }.onError {
+ AppLog.put("加载正文出错\n${it.localizedMessage}")
+ }
+ }
+ }
+ saveRead()
+ }
+ }
+
+ interface Callback {
+ fun loadContentFinish(list: MutableList)
+ fun loadComplete()
+ fun loadFail(msg: String)
+ fun noData()
+ fun adjustmentProgress()
+ fun sureNewProgress(progress: BookProgress)
+ val chapterList: MutableList
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt b/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt
new file mode 100644
index 000000000..c51aeba7a
--- /dev/null
+++ b/app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt
@@ -0,0 +1,116 @@
+package io.legado.app.model.recyclerView
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.ProgressBar
+import android.widget.TextView
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.core.view.updateLayoutParams
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import com.github.panpf.zoomimage.GlideZoomImageView
+import io.legado.app.R
+import io.legado.app.help.glide.progress.OnProgressListener
+import io.legado.app.help.glide.progress.ProgressManager
+import io.legado.app.model.BookCover
+import io.legado.app.model.ReadMange
+import io.legado.app.utils.getCompatDrawable
+import io.legado.app.utils.printOnDebug
+
+open class MangaVH(val binding: VB, private val context: Context) :
+ RecyclerView.ViewHolder(binding.root) {
+
+ protected lateinit var mLoading: ProgressBar
+ protected lateinit var mImage: GlideZoomImageView
+ protected lateinit var mProgress: TextView
+ protected lateinit var mFlProgress: FrameLayout
+ protected var mRetry: Button? = null
+
+ fun initComponent(
+ loading: ProgressBar,
+ image: GlideZoomImageView,
+ progress: TextView,
+ button: Button? = null,
+ flProgress: FrameLayout,
+ ) {
+ mLoading = loading
+ mImage = image
+ mRetry = button
+ mProgress = progress
+ mFlProgress = flProgress
+ }
+
+ @SuppressLint("CheckResult")
+ fun loadImageWithRetry(imageUrl: String) {
+ mFlProgress.isVisible = true
+ mLoading.isVisible = true
+ mRetry?.isGone = true
+ mProgress.isVisible = true
+ ProgressManager.removeListener(imageUrl)
+ ProgressManager.addListener(imageUrl, object : OnProgressListener {
+ @SuppressLint("SetTextI18n")
+ override fun invoke(
+ isComplete: Boolean,
+ percentage: Int,
+ bytesRead: Long,
+ totalBytes: Long,
+ ) {
+ mProgress.text = "${percentage}%"
+ }
+ })
+ try {
+ mImage.tag = imageUrl
+ BookCover.loadManga(
+ context,
+ imageUrl,
+ sourceOrigin = ReadMange.book?.origin,
+ manga = true,
+ useDefaultCover = context.getCompatDrawable(R.color.book_ant_10)
+ ).addListener(object : RequestListener {
+ override fun onLoadFailed(
+ e: GlideException?,
+ model: Any?,
+ target: Target,
+ isFirstResource: Boolean,
+ ): Boolean {
+ mFlProgress.isVisible = true
+ mLoading.isGone = true
+ mRetry?.isVisible = true
+ mProgress.isGone = true
+ itemView.updateLayoutParams {
+ height = MATCH_PARENT
+ }
+ return false
+ }
+
+ override fun onResourceReady(
+ resource: Drawable,
+ model: Any,
+ target: Target?,
+ dataSource: DataSource,
+ isFirstResource: Boolean,
+ ): Boolean {
+ mFlProgress.isGone = true
+ itemView.updateLayoutParams {
+ height = WRAP_CONTENT
+ }
+ return false
+ }
+ }).into(mImage)
+ } catch (e: Exception) {
+ e.printOnDebug()
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt b/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt
new file mode 100644
index 000000000..0f7160db0
--- /dev/null
+++ b/app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt
@@ -0,0 +1,11 @@
+package io.legado.app.model.recyclerView
+
+
+data class MangeContent(
+ var mChapterPagePos: Int = 0,//总章节位置
+ var mChapterPageCount:Int,//总章节数量
+ var mChapterNextPagePos: Int = 0,//下一章
+ val mImageUrl: String="",//当前URL
+ var mDurChapterPos:Int=0,//当前章节位置
+ var mDurChapterCount:Int=0//当前章节内容总数
+)
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt b/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt
new file mode 100644
index 000000000..ad7dcf9b1
--- /dev/null
+++ b/app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt
@@ -0,0 +1,8 @@
+package io.legado.app.model.recyclerView
+
+data class ReaderLoading(
+ val mChapterPagePos: Int = 0,
+ val mMessage: String? = null,
+ val mNextChapterIndex: Int = 0,
+ var mLoading: Boolean = false,
+)
diff --git a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt
index c14d3ba18..516b04afe 100644
--- a/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt
@@ -19,6 +19,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.theme.primaryTextColor
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.utils.applyNavigationBarPadding
@@ -26,7 +27,7 @@ import io.legado.app.utils.applyTint
import io.legado.app.utils.cnCompare
import io.legado.app.utils.getInt
import io.legado.app.utils.putInt
-import io.legado.app.utils.startActivity
+import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
@@ -76,16 +77,19 @@ class ReadRecordActivity : BaseActivity() {
item.isChecked = true
initData()
}
+
R.id.menu_sort_read_long -> {
sortMode = 1
item.isChecked = true
initData()
}
+
R.id.menu_sort_read_time -> {
sortMode = 2
item.isChecked = true
initData()
}
+
R.id.menu_enable_record -> {
AppConfig.enableReadRecord = !item.isChecked
}
@@ -165,7 +169,7 @@ class ReadRecordActivity : BaseActivity() {
holder: ItemViewHolder,
binding: ItemReadRecordBinding,
item: ReadRecordShow,
- payloads: MutableList
+ payloads: MutableList,
) {
binding.apply {
tvBookName.text = item.bookName
@@ -189,7 +193,7 @@ class ReadRecordActivity : BaseActivity() {
if (book == null) {
SearchActivity.start(this@ReadRecordActivity, item.bookName)
} else {
- startActivity {
+ startReadOrMangaActivity(book) {
putExtra("bookUrl", book.bookUrl)
}
}
diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
index da99ac719..fbcfb8523 100644
--- a/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
@@ -15,6 +15,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.permission.Permissions
import io.legado.app.lib.permission.PermissionsCompat
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.FileUtils
@@ -26,6 +27,7 @@ import io.legado.app.utils.isContentScheme
import io.legado.app.utils.readUri
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
+import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers.IO
@@ -90,8 +92,8 @@ class FileAssociationActivity :
}
viewModel.openBookLiveData.observe(this) {
binding.rotateLoading.gone()
- startActivity {
- putExtra("bookUrl", it)
+ startReadOrMangaActivity(it) {
+ putExtra("bookUrl", it.bookUrl)
}
finish()
}
diff --git a/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
index f16beb563..08a03674c 100644
--- a/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
+++ b/app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
@@ -6,13 +6,14 @@ import androidx.lifecycle.MutableLiveData
import io.legado.app.constant.AppLog
import io.legado.app.constant.AppPattern
import io.legado.app.constant.AppPattern.bookFileRegex
+import io.legado.app.data.entities.Book
import io.legado.app.model.localBook.LocalBook
import io.legado.app.utils.*
class FileAssociationViewModel(application: Application) : BaseAssociationViewModel(application) {
val importBookLiveData = MutableLiveData()
val onLineImportLive = MutableLiveData()
- val openBookLiveData = MutableLiveData()
+ val openBookLiveData = MutableLiveData()
val notSupportedLiveData = MutableLiveData>()
fun dispatchIntent(uri: Uri) {
@@ -60,6 +61,6 @@ class FileAssociationViewModel(application: Application) : BaseAssociationViewMo
fun importBook(uri: Uri) {
val book = LocalBook.importFile(uri)
- openBookLiveData.postValue(book.bookUrl)
+ openBookLiveData.postValue(book)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
index 747ccd744..bab16cdab 100644
--- a/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
@@ -31,6 +31,7 @@ import io.legado.app.model.BookCover
import io.legado.app.service.AudioPlayService
import io.legado.app.ui.about.AppLogDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.source.edit.BookSourceEditActivity
import io.legado.app.ui.book.toc.TocActivityResult
@@ -45,6 +46,7 @@ import io.legado.app.utils.observeEventSticky
import io.legado.app.utils.sendToClip
import io.legado.app.utils.showDialogFragment
import io.legado.app.utils.startActivity
+import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import io.legado.app.utils.visible
import kotlinx.coroutines.Dispatchers.IO
@@ -218,7 +220,7 @@ class AudioPlayActivity :
AudioPlay.book?.delete()
appDb.bookDao.insert(book)
}
- startActivity {
+ startReadOrMangaActivity(book) {
putExtra("bookUrl", book.bookUrl)
}
finish()
diff --git a/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt
index aec1cf321..d61d298d6 100644
--- a/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt
@@ -2,19 +2,20 @@ package io.legado.app.ui.book.import
import android.os.Bundle
import android.view.MotionEvent
-import android.widget.EditText
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.ViewModel
import io.legado.app.R
import io.legado.app.base.VMBaseActivity
import io.legado.app.constant.AppPattern
import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
import io.legado.app.databinding.ActivityImportBookBinding
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.alert
import io.legado.app.lib.dialogs.selector
import io.legado.app.lib.theme.primaryTextColor
import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.file.HandleFileContract
import io.legado.app.utils.ArchiveUtils
@@ -22,7 +23,7 @@ import io.legado.app.utils.FileDoc
import io.legado.app.utils.applyTint
import io.legado.app.utils.hideSoftInput
import io.legado.app.utils.shouldHideSoftInput
-import io.legado.app.utils.startActivity
+import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlin.coroutines.resume
@@ -97,9 +98,9 @@ abstract class BaseImportBookActivity : VMBaseActivity {
- putExtra("bookUrl", bookUrl)
+ protected fun startReadBook(book: Book) {
+ startReadOrMangaActivity(book) {
+ putExtra("bookUrl", book.bookUrl)
}
}
@@ -110,7 +111,7 @@ abstract class BaseImportBookActivity : VMBaseActivity : VMBaseActivity
appDb.bookDao.getBookByFileName(name)?.let {
- startReadBook(it.bookUrl)
+ startReadBook(it)
} ?: showImportAlert(fileDoc, name)
}
}
@@ -136,12 +137,12 @@ abstract class BaseImportBookActivity : VMBaseActivity Unit
+ onSuccess: (Book) -> Unit
) {
LocalBook.importArchiveFile(fileDoc.uri, fileName) {
it.contains(fileName)
}.firstOrNull()?.run {
- onSuccess.invoke(bookUrl)
+ onSuccess.invoke(this)
}
}
diff --git a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt
index 5eb7c58cd..0901d1d0e 100644
--- a/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt
@@ -304,7 +304,7 @@ class ImportBookActivity : BaseImportBookActivity(),
override fun startRead(fileDoc: FileDoc) {
if (!ArchiveUtils.isArchive(fileDoc.name)) {
appDb.bookDao.getBookByFileName(fileDoc.name)?.let {
- startReadBook(it.bookUrl)
+ startReadBook(it)
}
} else {
onArchiveFileClick(fileDoc)
diff --git a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt
index 5951f7813..5c30c8cd3 100644
--- a/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt
@@ -228,7 +228,7 @@ class RemoteBookActivity : BaseImportBookActivity(),
val downloadFileName = remoteBook.filename
if (!ArchiveUtils.isArchive(downloadFileName)) {
appDb.bookDao.getBookByFileName(downloadFileName)?.let {
- startReadBook(it.bookUrl)
+ startReadBook(it)
}
} else {
AppConfig.defaultBookTreeUri ?: return
diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
index 6c968978b..98c33a4a9 100644
--- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
+++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
@@ -27,6 +27,7 @@ import io.legado.app.help.AppWebDav
import io.legado.app.help.book.addType
import io.legado.app.help.book.getRemoteUrl
import io.legado.app.help.book.isAudio
+import io.legado.app.help.book.isImage
import io.legado.app.help.book.isLocal
import io.legado.app.help.book.isLocalTxt
import io.legado.app.help.book.isWebFile
@@ -47,6 +48,7 @@ import io.legado.app.ui.book.changecover.ChangeCoverDialog
import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
import io.legado.app.ui.book.group.GroupSelectDialog
import io.legado.app.ui.book.info.edit.BookInfoEditActivity
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
import io.legado.app.ui.book.search.SearchActivity
@@ -310,7 +312,7 @@ class BookInfoActivity :
private fun upLoadBook(
book: Book,
- bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav
+ bookWebDav: RemoteBookWebDav? = AppWebDav.defaultBookWebDav,
) {
lifecycleScope.launch {
waitDialog.setText("上传中.....")
@@ -604,7 +606,7 @@ class BookInfoActivity :
}
private fun showWebFileDownloadAlert(
- onClick: ((Book) -> Unit)? = null
+ onClick: ((Book) -> Unit)? = null,
) {
val webFiles = viewModel.webFiles
if (webFiles.isEmpty()) {
@@ -653,7 +655,7 @@ class BookInfoActivity :
private fun showDecompressFileImportAlert(
archiveFileUri: Uri,
fileNames: List,
- success: ((Book) -> Unit)? = null
+ success: ((Book) -> Unit)? = null,
) {
if (fileNames.isEmpty()) {
toastOnUi(R.string.unsupport_archivefile_entry)
@@ -693,7 +695,10 @@ class BookInfoActivity :
)
else -> readBookResult.launch(
- Intent(this, ReadBookActivity::class.java)
+ Intent(
+ this,
+ if (book.isImage&&AppConfig.showMangaUi) ReadMangaActivity::class.java else ReadBookActivity::class.java
+ )
.putExtra("bookUrl", book.bookUrl)
.putExtra("inBookshelf", viewModel.inBookshelf)
.putExtra("tocChanged", tocChanged)
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt b/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt
new file mode 100644
index 000000000..7c552d1bc
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt
@@ -0,0 +1,240 @@
+package io.legado.app.ui.book.manga
+
+import android.app.Application
+import android.content.Intent
+import io.legado.app.R
+import io.legado.app.base.BaseViewModel
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
+import io.legado.app.constant.EventBus
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isLocalModified
+import io.legado.app.help.book.removeType
+import io.legado.app.help.book.simulatedTotalChapterNum
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.model.ReadMange
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.mapParallelSafe
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onEmpty
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.take
+
+class MangaViewModel(application: Application) : BaseViewModel(application) {
+
+ private var changeSourceCoroutine: Coroutine<*>? = null
+
+ /**
+ * 初始化
+ */
+ fun initData(intent: Intent, success: (() -> Unit)? = null) {
+ execute {
+ ReadMange.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
+ ReadMange.tocChanged = intent.getBooleanExtra("tocChanged", false)
+ val bookUrl = intent.getStringExtra("bookUrl")
+ val book = when {
+ bookUrl.isNullOrEmpty() -> appDb.bookDao.lastReadBook
+ else -> appDb.bookDao.getBook(bookUrl)
+ } ?: ReadMange.book
+ when {
+ book != null -> initMange(book)
+ else -> context.getString(R.string.no_book)//没有找到书
+ }
+ }.onSuccess {
+ success?.invoke()
+ }.onError {
+ val msg = "初始化数据失败\n${it.localizedMessage}"
+ AppLog.put(msg, it)
+ }.onFinally {
+ ReadMange.saveRead()
+ }
+ }
+
+ private suspend fun initMange(book: Book) {
+ val isSameBook = ReadMange.book?.bookUrl == book.bookUrl
+ if (isSameBook) {
+ ReadMange.upData(book)
+ } else {
+ ReadMange.resetData(book)
+ }
+ if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
+ return
+ }
+
+ if (book.isLocal && !checkLocalBookFileExist(book)) {
+ return
+ }
+
+ if ((ReadMange.durChapterPageCount == 0 || book.isLocalModified()) && !loadChapterListAwait(
+ book
+ )
+ ) {
+ return
+ }
+ ensureChapterExist()
+
+ //开始加载内容
+ ReadMange.loadContent()
+
+ //自动换源
+ if (!book.isLocal && ReadMange.bookSource == null) {
+ autoChangeSource(book.name, book.author)
+ return
+ }
+ }
+
+ private suspend fun loadChapterListAwait(book: Book): Boolean {
+ ReadMange.bookSource?.let {
+ val oldBook = book.copy()
+ WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
+ if (oldBook.bookUrl == book.bookUrl) {
+ appDb.bookDao.update(book)
+ } else {
+ appDb.bookDao.insert(book)
+ BookHelp.updateCacheFolder(oldBook, book)
+ }
+ appDb.bookChapterDao.delByBook(oldBook.bookUrl)
+ appDb.bookChapterDao.insert(*cList.toTypedArray())
+ ReadMange.durChapterPageCount = cList.size
+ ReadMange.simulatedChapterSize = book.simulatedTotalChapterNum()
+ return true
+ }.onFailure {
+ //加载章节出错
+ ReadMange.mCallback?.loadFail("加载目录失败")
+ return false
+ }
+ }
+
+ return true
+
+ }
+
+ /**
+ * 加载详情页
+ */
+ private suspend fun loadBookInfo(book: Book): Boolean {
+ val source = ReadMange.bookSource ?: return true
+ try {
+ WebBook.getBookInfoAwait(source, book, canReName = false)
+ return true
+ } catch (e: Throwable) {
+ // 加载详情页失败
+// ReadBook.upMsg("详情页出错: ${e.localizedMessage}")
+ return false
+ }
+ }
+
+ private fun ensureChapterExist() {
+ if (ReadMange.simulatedChapterSize > 0 && ReadMange.durChapterPagePos > ReadMange.simulatedChapterSize - 1) {
+ ReadMange.durChapterPagePos = ReadMange.simulatedChapterSize - 1
+ }
+ }
+
+ /**
+ * 自动换源
+ */
+ private fun autoChangeSource(name: String, author: String) {
+ if (!AppConfig.autoChangeSource) return
+ execute {
+ val sources = appDb.bookSourceDao.allTextEnabledPart
+ flow {
+ for (source in sources) {
+ source.getBookSource()?.let {
+ emit(it)
+ }
+ }
+ }.onStart {
+ // 自动换源
+
+ }.mapParallelSafe(AppConfig.threadCount) { source ->
+ val book = WebBook.preciseSearchAwait(this, source, name, author).getOrThrow()
+ if (book.tocUrl.isEmpty()) {
+ WebBook.getBookInfoAwait(source, book)
+ }
+ val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
+ val chapter = toc.getOrElse(book.durChapterIndex) {
+ toc.last()
+ }
+ val nextChapter = toc.getOrElse(chapter.index) {
+ toc.first()
+ }
+ WebBook.getContentAwait(
+ bookSource = source,
+ book = book,
+ bookChapter = chapter,
+ nextChapterUrl = nextChapter.url
+ )
+ book to toc
+ }.take(1).onEach { (book, toc) ->
+ changeTo(book, toc)
+ }.onEmpty {
+ throw NoStackTraceException("没有合适书源")
+ }.onCompletion {
+ // 换源完成
+ }.catch {
+ AppLog.put("自动换源失败\n${it.localizedMessage}", it)
+ context.toastOnUi("自动换源失败\n${it.localizedMessage}")
+ }.collect()
+ }
+ }
+
+ /**
+ * 换源
+ */
+ fun changeTo(book: Book, toc: List) {
+ changeSourceCoroutine?.cancel()
+ changeSourceCoroutine = execute {
+ //换源中
+ ReadMange.book?.migrateTo(book, toc)
+ book.removeType(BookType.updateError)
+ ReadMange.book?.delete()
+ appDb.bookDao.insert(book)
+ appDb.bookChapterDao.insert(*toc.toTypedArray())
+ ReadMange.resetData(book)
+ toc.find { it.title.contains(ReadMange.chapterTitle) }?.run {
+ ReadMange.loadContent(index)
+ } ?: ReadMange.loadContent()
+ }.onError {
+ AppLog.put("换源失败\n$it", it, true)
+ }.onFinally {
+ postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+ }
+ }
+
+ private fun checkLocalBookFileExist(book: Book): Boolean {
+ try {
+ LocalBook.getBookInputStream(book)
+ return true
+ } catch (e: Throwable) {
+ return false
+ }
+ }
+
+ fun openChapter(index: Int, durChapterPos: Int = 0) {
+ if (index < ReadMange.durChapterPageCount) {
+ ReadMange.chapterChanged = true
+ ReadMange.durChapterPagePos = index
+ ReadMange.durChapterPos = durChapterPos
+ ReadMange.saveRead()
+ ReadMange.loadContent(index)
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ changeSourceCoroutine?.cancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
new file mode 100644
index 000000000..660ab693f
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
@@ -0,0 +1,483 @@
+package io.legado.app.ui.book.manga
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.Looper
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.WindowInsets
+import androidx.activity.viewModels
+import androidx.appcompat.app.AlertDialog
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.bumptech.glide.Glide
+import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
+import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
+import com.bumptech.glide.util.FixedPreloadSizeProvider
+import io.legado.app.BuildConfig
+import io.legado.app.R
+import io.legado.app.base.VMBaseActivity
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookProgress
+import io.legado.app.data.entities.BookSource
+import io.legado.app.databinding.ActivityMangeBinding
+import io.legado.app.databinding.ViewLoadMoreBinding
+import io.legado.app.help.book.isImage
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.storage.Backup
+import io.legado.app.lib.dialogs.alert
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.model.ReadMange
+import io.legado.app.model.ReadMange.mFirstLoading
+import io.legado.app.model.recyclerView.MangeContent
+import io.legado.app.model.recyclerView.ReaderLoading
+import io.legado.app.receiver.NetworkChangedListener
+import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
+import io.legado.app.ui.book.info.BookInfoActivity
+import io.legado.app.ui.book.manga.rv.MangaAdapter
+import io.legado.app.ui.book.read.MangaMenu
+import io.legado.app.ui.book.read.ReadBookActivity.Companion.RESULT_DELETED
+import io.legado.app.ui.book.toc.TocActivityResult
+import io.legado.app.ui.widget.recycler.LoadMoreView
+import io.legado.app.utils.ColorUtils
+import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.StartActivityContract
+import io.legado.app.utils.getCompatColor
+import io.legado.app.utils.gone
+import io.legado.app.utils.immersionFullScreen
+import io.legado.app.utils.printOnDebug
+import io.legado.app.utils.setLightStatusBar
+import io.legado.app.utils.setNavigationBarColorAuto
+import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.toastOnUi
+import io.legado.app.utils.viewbindingdelegate.viewBinding
+import io.legado.app.utils.visible
+
+class ReadMangaActivity : VMBaseActivity(),
+ ReadMange.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack {
+
+ private val menuLayoutIsVisible get() = binding.mangaMenu.isVisible
+
+ private val mLayoutManager by lazy {
+ LinearLayoutManager(this@ReadMangaActivity)
+ }
+ private val mAdapter: MangaAdapter by lazy {
+ MangaAdapter(this@ReadMangaActivity)
+ }
+ private val mSizeProvider by lazy {
+ FixedPreloadSizeProvider(
+ this@ReadMangaActivity.resources.displayMetrics.widthPixels,
+ SIZE_ORIGINAL
+ )
+ }
+
+ private val mRecyclerViewPreloader by lazy {
+ RecyclerViewPreloader(Glide.with(this), mAdapter, mSizeProvider, 10)
+ }
+
+ private val networkChangedListener by lazy {
+ NetworkChangedListener(this)
+ }
+
+ private var justInitData: Boolean = false
+ private var syncDialog: AlertDialog? = null
+
+ private val loadMoreView by lazy {
+ LoadMoreView(this).apply {
+ setBackgroundColor(getCompatColor(R.color.book_ant_10))
+ getLoading().loadingColor = getCompatColor(R.color.white)
+ getLoadingText().setTextColor(getCompatColor(R.color.white))
+ }
+ }
+
+ //打开目录返回选择章节返回结果
+ private val tocActivity =
+ registerForActivityResult(TocActivityResult()) {
+ it?.let {
+ binding.flLoading.isVisible = true
+ viewModel.openChapter(it.first, it.second)
+ }
+ }
+ private val bookInfoActivity =
+ registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
+ if (it.resultCode == RESULT_OK) {
+ setResult(RESULT_DELETED)
+ super.finish()
+ }
+ }
+ override val binding by viewBinding(ActivityMangeBinding::inflate)
+ override val viewModel by viewModels()
+
+ override fun onActivityCreated(savedInstanceState: Bundle?) {
+ immersionFullScreen(WindowInsetsControllerCompat(window, binding.root))
+ ReadMange.register(this)
+ binding.mRecyclerMange.run {
+ adapter = mAdapter
+ itemAnimator = null
+ layoutManager = mLayoutManager
+ setHasFixedSize(true)
+ mLayoutManager.initialPrefetchItemCount = 4
+ mLayoutManager.isItemPrefetchEnabled = true
+ setItemViewCacheSize(AppConfig.preDownloadNum)
+ setPreScrollListener { _, _, dy, position ->
+ if (dy > 0 && position + 2 > mAdapter.getCurrentList().size - 3) {
+ if (mAdapter.getCurrentList().last() is ReaderLoading) {
+ val nextIndex =
+ (mAdapter.getCurrentList().last() as ReaderLoading).mNextChapterIndex
+ if (nextIndex != -1) {
+ scrollToBottom(false, nextIndex)
+ }
+ }
+ }
+ }
+ setNestedPreScrollListener { _, _, _, position ->
+ if (mAdapter.getCurrentList()
+ .isNotEmpty() && position <= mAdapter.getCurrentList().lastIndex
+ ) {
+ try {
+ val content = mAdapter.getCurrentList()[position]
+ if (content is MangeContent) {
+ ReadMange.durChapterPos = content.mDurChapterPos.minus(1)
+ upText(
+ content.mChapterPagePos,
+ content.mChapterPageCount,
+ content.mDurChapterPos,
+ content.mDurChapterCount
+ )
+ }
+ } catch (e: Exception) {
+ e.printOnDebug()
+ }
+
+ }
+ }
+ addOnScrollListener(mRecyclerViewPreloader)
+ onToucheMiddle {
+ if (!binding.mangaMenu.isVisible) {
+ binding.mangaMenu.runMenuIn()
+ }
+ }
+ }
+ binding.retry.setOnClickListener {
+ binding.llLoading.isVisible = true
+ binding.llRetry.isGone = true
+ mFirstLoading = false
+ ReadMange.loadContent()
+ }
+
+ mAdapter.addFooterView {
+ ViewLoadMoreBinding.bind(loadMoreView)
+ }
+ loadMoreView.setOnClickListener {
+ if (!loadMoreView.isLoading && !ReadMange.gameOver) {
+ scrollToBottom(true, ReadMange.durChapterPagePos)
+ }
+ }
+ loadMoreView.gone()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ viewModel.initData(intent)
+ }
+
+ private fun scrollToBottom(forceLoad: Boolean = false, index: Int) {
+ if ((loadMoreView.hasMore && !loadMoreView.isLoading) && !ReadMange.gameOver || forceLoad) {
+ loadMoreView.hasMore()
+ ReadMange.moveToNextChapter(index)
+ }
+ }
+
+ override fun onPostCreate(savedInstanceState: Bundle?) {
+ super.onPostCreate(savedInstanceState)
+ Looper.myQueue().addIdleHandler {
+ viewModel.initData(intent)
+ false
+ }
+ justInitData = true
+ }
+
+ override fun loadContentFinish(list: MutableList) {
+ if (!this.isDestroyed) {
+ mAdapter.submitList(list) {
+ if (!mFirstLoading) {
+ if (list.size > 1) {
+ binding.infobar.isVisible = true
+ upText(
+ ReadMange.durChapterPagePos,
+ ReadMange.durChapterPageCount,
+ ReadMange.durChapterPos,
+ ReadMange.durChapterCount
+ )
+ }
+
+ if (ReadMange.durChapterPos + 2 > mAdapter.getCurrentList().size - 3) {
+ val nextIndex =
+ (mAdapter.getCurrentList().last() as ReaderLoading).mNextChapterIndex
+ scrollToBottom(index = nextIndex)
+ } else {
+ binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
+ }
+ }
+
+ if (ReadMange.chapterChanged) {
+ binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
+ }
+
+ ReadMange.chapterChanged = false
+ loadMoreView.visible()
+ mFirstLoading = true
+ loadMoreView.stopLoad()
+ }
+ }
+ }
+
+ private fun upText(
+ chapterPagePos: Int, chapterPageCount: Int, chapterPos: Int, chapterCount: Int,
+ ) {
+ binding.infobar.update(
+ chapterPagePos,
+ chapterPageCount,
+ chapterPagePos.minus(1f).div(chapterPageCount.minus(1f)),
+ chapterPos,
+ chapterCount
+ )
+ }
+
+ override fun onResume() {
+ super.onResume()
+ networkChangedListener.register()
+ networkChangedListener.onNetworkChanged = {
+ // 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑)
+ if (AppConfig.syncBookProgressPlus && NetworkUtils.isAvailable()&&!justInitData) {
+ ReadMange.syncProgress({ progress -> sureNewProgress(progress) })
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ if (ReadMange.inBookshelf) {
+ ReadMange.saveRead()
+ if (!BuildConfig.DEBUG) {
+ if (AppConfig.syncBookProgressPlus) {
+ ReadMange.syncProgress()
+ } else {
+ ReadMange.uploadProgress()
+ }
+ Backup.autoBack(this)
+ }
+ }
+ networkChangedListener.unRegister()
+ }
+
+ override fun loadComplete() {
+ binding.flLoading.isGone = true
+ }
+
+ override fun loadFail(msg: String) {
+ if (!mFirstLoading || ReadMange.chapterChanged) {
+ binding.llLoading.isGone = true
+ binding.llRetry.isVisible = true
+ binding.tvMsg.text = msg
+ } else {
+ loadMoreView.error(null, "加载失败,点击重试")
+ }
+ }
+
+ override fun noData() {
+ loadMoreView.noMore("暂无章节了!")
+ }
+
+ override fun adjustmentProgress() {
+ if (ReadMange.chapterChanged) {
+ binding.mRecyclerMange.scrollToPosition(ReadMange.durChapterPos)
+ binding.flLoading.isGone = true
+ }
+ }
+
+ override val chapterList: MutableList
+ get() = mAdapter.getCurrentList()
+
+ override fun onDestroy() {
+ ReadMange.unregister()
+ super.onDestroy()
+ }
+
+ override fun onLowMemory() {
+ super.onLowMemory()
+ Glide.get(this).clearMemory()
+ }
+
+ override fun sureNewProgress(progress: BookProgress) {
+ syncDialog?.dismiss()
+ syncDialog = alert(R.string.get_book_progress) {
+ setMessage(R.string.cloud_progress_exceeds_current)
+ okButton {
+ binding.flLoading.isVisible = true
+ ReadMange.setProgress(progress)
+ }
+ noButton()
+ }
+ }
+
+ override val oldBook: Book?
+ get() = ReadMange.book
+
+ override fun changeTo(source: BookSource, book: Book, toc: List) {
+ if (book.isImage) {
+ binding.flLoading.isVisible = true
+ ReadMange.chapterChanged = true
+ viewModel.changeTo(book, toc)
+ } else {
+ toastOnUi("所选择的源不是漫画源")
+ }
+ }
+
+
+ override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
+ menuInflater.inflate(R.menu.book_manga, menu)
+ return super.onCompatCreateOptionsMenu(menu)
+ }
+
+ /**
+ * 菜单
+ */
+ override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
+ when (item.itemId) {
+ R.id.menu_change_source -> {
+ binding.mangaMenu.runMenuOut()
+ ReadMange.book?.let {
+ showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
+ }
+ }
+
+ R.id.menu_catalog -> {
+ ReadMange.book?.let {
+ tocActivity.launch(it.bookUrl)
+ }
+ }
+ }
+ return super.onCompatOptionsItemSelected(item)
+ }
+
+ override fun upNavigationBarColor() {
+ when {
+ binding.mangaMenu.isVisible -> super.upNavigationBarColor()
+ !AppConfig.immNavigationBar -> super.upNavigationBarColor()
+ else -> setNavigationBarColorAuto(ReadBookConfig.bgMeanColor)
+ }
+ }
+
+ @SuppressLint("RtlHardcoded")
+ private fun upNavigationBar(value: Boolean) {
+ binding.mangaMenu.isVisible = value
+ }
+
+ override fun openBookInfoActivity() {
+ ReadMange.book?.let {
+ bookInfoActivity.launch {
+ putExtra("name", it.name)
+ putExtra("author", it.author)
+ }
+ }
+ }
+
+ override fun upSystemUiVisibility(value: Boolean) {
+ upSystemUiVisibility(isInMultiWindow, !menuLayoutIsVisible, false)
+ upNavigationBarColor()
+ upNavigationBar(value)
+ if (!value) {
+ immersionFullScreen(WindowInsetsControllerCompat(window, binding.root))
+ }
+ }
+
+ /**
+ * 更新状态栏,导航栏
+ */
+ fun upSystemUiVisibility(
+ isInMultiWindow: Boolean,
+ toolBarHide: Boolean = true,
+ useBgMeanColor: Boolean = false,
+ ) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ window.insetsController?.run {
+ if (toolBarHide && ReadBookConfig.hideNavigationBar) {
+ hide(WindowInsets.Type.navigationBars())
+ } else {
+ show(WindowInsets.Type.navigationBars())
+ }
+ if (toolBarHide && ReadBookConfig.hideStatusBar) {
+ hide(WindowInsets.Type.statusBars())
+ } else {
+ show(WindowInsets.Type.statusBars())
+ }
+ }
+ }
+ upSystemUiVisibilityO(isInMultiWindow, toolBarHide)
+ if (toolBarHide) {
+ setLightStatusBar(ReadBookConfig.durConfig.curStatusIconDark())
+ } else {
+ val statusBarColor =
+ if (AppConfig.readBarStyleFollowPage
+ && ReadBookConfig.durConfig.curBgType() == 0
+ || useBgMeanColor
+ ) {
+ ReadBookConfig.bgMeanColor
+ } else {
+ ThemeStore.statusBarColor(this, AppConfig.isTransparentStatusBar)
+ }
+ setLightStatusBar(ColorUtils.isColorLight(statusBarColor))
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun upSystemUiVisibilityO(
+ isInMultiWindow: Boolean,
+ toolBarHide: Boolean = true,
+ ) {
+ var flag = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ or View.SYSTEM_UI_FLAG_IMMERSIVE
+ or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
+ if (!isInMultiWindow) {
+ flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ }
+ if (ReadBookConfig.hideNavigationBar) {
+ flag = flag or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ if (toolBarHide) {
+ flag = flag or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ }
+ }
+ if (ReadBookConfig.hideStatusBar && toolBarHide) {
+ flag = flag or View.SYSTEM_UI_FLAG_FULLSCREEN
+ }
+ window.decorView.systemUiVisibility = flag
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ val keyCode = event.keyCode
+ val action = event.action
+ val isDown = action == 0
+
+ if (keyCode == KeyEvent.KEYCODE_MENU) {
+ if (isDown && !binding.mangaMenu.canShowMenu) {
+ binding.mangaMenu.runMenuIn()
+ return true
+ }
+ if (!isDown && !binding.mangaMenu.canShowMenu) {
+ binding.mangaMenu.canShowMenu = true
+ return true
+ }
+ }
+ return super.dispatchKeyEvent(event)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt
new file mode 100644
index 000000000..cc5397d47
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt
@@ -0,0 +1,64 @@
+package io.legado.app.ui.book.manga.rv
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import kotlin.math.abs
+
+open class GestureDetectorWithLongTap(
+ context: Context,
+ listener: Listener,
+) : GestureDetector(context, listener) {
+
+ private val handler = Handler(Looper.getMainLooper())
+ private val slop = ViewConfiguration.get(context).scaledTouchSlop
+ private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
+ private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
+
+ private var downX = 0f
+ private var downY = 0f
+ private var lastUp = 0L
+ private var lastDownEvent: MotionEvent? = null
+
+
+ private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
+
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
+ when (ev.actionMasked) {
+ MotionEvent.ACTION_DOWN -> {
+ lastDownEvent?.recycle()
+ lastDownEvent = MotionEvent.obtain(ev)
+
+ if (ev.downTime - lastUp > doubleTapTime) {
+ downX = ev.rawX
+ downY = ev.rawY
+ handler.postDelayed(longTapFn, longTapTime)
+ }
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
+ handler.removeCallbacks(longTapFn)
+ }
+ }
+
+ MotionEvent.ACTION_UP -> {
+ lastUp = ev.eventTime
+ handler.removeCallbacks(longTapFn)
+ }
+
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
+ handler.removeCallbacks(longTapFn)
+ }
+ }
+ return super.onTouchEvent(ev)
+ }
+
+ open class Listener : SimpleOnGestureListener() {
+ open fun onLongTapConfirmed(ev: MotionEvent) {
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt
new file mode 100644
index 000000000..feee936d7
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt
@@ -0,0 +1,230 @@
+package io.legado.app.ui.book.manga.rv
+
+import android.content.Context
+import android.util.SparseArray
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import androidx.annotation.IntRange
+import androidx.core.view.updateLayoutParams
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import androidx.viewbinding.ViewBinding
+import com.bumptech.glide.Glide
+import com.bumptech.glide.ListPreloader.PreloadModelProvider
+import com.bumptech.glide.RequestBuilder
+import com.github.panpf.zoomimage.zoom.GestureType
+import io.legado.app.R
+import io.legado.app.base.adapter.ItemViewHolder
+import io.legado.app.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW
+import io.legado.app.databinding.BookComicLoadingRvBinding
+import io.legado.app.databinding.BookComicRvBinding
+import io.legado.app.help.glide.progress.ProgressManager
+import io.legado.app.model.BookCover
+import io.legado.app.model.ReadMange
+import io.legado.app.model.recyclerView.MangaVH
+import io.legado.app.model.recyclerView.MangeContent
+import io.legado.app.model.recyclerView.ReaderLoading
+import io.legado.app.utils.getCompatDrawable
+import java.util.Collections
+
+
+class MangaAdapter(private val context: Context) :
+ RecyclerView.Adapter(), PreloadModelProvider {
+
+ companion object {
+ private const val LOADING_VIEW = 0
+ private const val CONTENT_VIEW = 1
+ }
+
+ private val mDiffCallback: DiffUtil.ItemCallback = object : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
+ return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
+ newItem.mMessage == oldItem.mMessage
+ } else if (oldItem is MangeContent && newItem is MangeContent) {
+ oldItem.mImageUrl == newItem.mImageUrl
+ } else false
+ }
+
+ override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
+ return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
+ oldItem == newItem
+ } else if (oldItem is MangeContent && newItem is MangeContent) {
+ oldItem == newItem
+ } else false
+ }
+ }
+
+ private val mDiffer = AsyncListDiffer(this, mDiffCallback)
+
+ private fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList[position]
+
+ fun getCurrentList() = mDiffer.currentList
+
+ //全部替换数据
+ fun submitList(contents: MutableList, runnable: Runnable) {
+ val currentList = mDiffer.currentList.toMutableList()
+ currentList.addAll(contents)
+ if (ReadMange.chapterChanged) {
+ mDiffer.submitList(contents) {
+ runnable.run()
+ }
+ } else {
+ mDiffer.submitList(currentList) {
+ runnable.run()
+ }
+ }
+
+ }
+
+ inner class PageViewHolder(binding: BookComicRvBinding) :
+ MangaVH(binding, context) {
+
+ init {
+ initComponent(
+ binding.loading,
+ binding.image,
+ binding.progress,
+ binding.retry,
+ binding.flProgress
+ )
+ binding.image.zoomable.disabledGestureTypesState.value =
+ GestureType.DOUBLE_TAP_SCALE or GestureType.ONE_FINGER_SCALE or
+ GestureType.TWO_FINGER_SCALE or GestureType.KEYBOARD_DRAG or
+ GestureType.ONE_FINGER_DRAG or GestureType.KEYBOARD_SCALE or GestureType.MOUSE_WHEEL_SCALE
+ binding.retry.setOnClickListener {
+ val item = mDiffer.currentList[layoutPosition]
+ if (item is MangeContent) {
+ loadImageWithRetry(item.mImageUrl)
+ }
+ }
+ }
+
+ fun onBind(item: MangeContent) {
+ loadImageWithRetry(item.mImageUrl)
+ }
+ }
+
+ inner class PageMoreViewHolder(val binding: BookComicLoadingRvBinding) :
+ RecyclerView.ViewHolder(binding.root) {
+ fun onBind(item: ReaderLoading) {
+ val message = item.mMessage
+ binding.text.text = message
+ }
+ }
+
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
+
+ return when {
+
+ viewType >= TYPE_FOOTER_VIEW -> {
+ ItemViewHolder(footerItems.get(viewType).invoke(parent))
+ }
+
+ viewType == LOADING_VIEW -> PageMoreViewHolder(
+ BookComicLoadingRvBinding.inflate(
+ LayoutInflater.from(
+ parent.context
+ ), parent, false
+ )
+ )
+
+ viewType == CONTENT_VIEW -> PageViewHolder(
+ BookComicRvBinding.inflate(
+ LayoutInflater.from(parent.context),
+ parent,
+ false
+ )
+ )
+
+
+ else -> error("Unknown view type!")
+ }
+ }
+
+ override fun getItemCount(): Int = getActualItemCount() + getFooterCount()
+
+ override fun getItemViewType(position: Int): Int {
+ return when {
+ isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount()
+ getItem(position) is MangeContent -> CONTENT_VIEW
+ getItem(position) is ReaderLoading -> LOADING_VIEW
+ else -> error("Unknown view type!")
+ }
+ }
+
+ fun getFooterCount() = footerItems.size()
+
+ private fun isFooter(position: Int) = position >= getActualItemCount()
+
+ override fun onViewRecycled(vh: RecyclerView.ViewHolder) {
+ super.onViewRecycled(vh)
+ when (vh) {
+ is PageViewHolder -> {
+ vh.itemView.updateLayoutParams {
+ height = MATCH_PARENT
+ }
+ Glide.with(context).clear(vh.binding.image)
+ if (vh.binding.image.tag is String) {
+ ProgressManager.removeListener(vh.binding.image.tag as String)
+ }
+ }
+ }
+ }
+
+ override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) {
+ when (vh) {
+ is PageViewHolder -> vh.onBind(getItem(position) as MangeContent)
+ is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading)
+ }
+ }
+
+
+ private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
+
+ @Synchronized
+ fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
+ kotlin.runCatching {
+ val index = getActualItemCount() + footerItems.size()
+ footerItems.put(TYPE_FOOTER_VIEW + footerItems.size(), footer)
+ notifyItemInserted(index)
+ }
+ }
+
+ /**
+ * 除去header和footer
+ */
+ fun getActualItemCount() = getCurrentList().size
+
+ @Synchronized
+ fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
+ kotlin.runCatching {
+ val index = footerItems.indexOfValue(footer)
+ if (index >= 0) {
+ footerItems.remove(index)
+ notifyItemRemoved(getActualItemCount() + index - 2)
+ }
+ }
+ }
+
+ override fun getPreloadItems(position: Int): MutableList {
+ if (getCurrentList().isEmpty()) return Collections.emptyList()
+ if (position >= getCurrentList().size) return Collections.emptyList()
+ return getCurrentList().subList(position, position + 1)
+ }
+
+ override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? {
+ if (item is MangeContent) {
+ return BookCover.loadManga(
+ context,
+ item.mImageUrl,
+ sourceOrigin = ReadMange.book?.origin,
+ manga = true,
+ useDefaultCover = context.getCompatDrawable(R.color.book_ant_10)
+ )
+ }
+ return null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt
new file mode 100644
index 000000000..276425149
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt
@@ -0,0 +1,85 @@
+package io.legado.app.ui.book.manga.rv
+
+import android.content.Context
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.view.GestureDetector
+import android.view.MotionEvent
+import android.view.ScaleGestureDetector
+import android.widget.FrameLayout
+import io.legado.app.help.config.AppConfig
+
+class WebtoonFrame : FrameLayout {
+
+ constructor(context: Context) : super(context)
+ constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
+ constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
+ context,
+ attrs,
+ defStyle
+ )
+
+ private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
+ private val flingDetector = GestureDetector(context, FlingListener())
+
+ var doubleTapZoom = true
+ set(value) {
+ field = value
+ recycler?.doubleTapZoom = value
+ scaleDetector.isQuickScaleEnabled = value
+ }
+
+ private val recycler: WebtoonRecyclerView?
+ get() = getChildAt(0) as? WebtoonRecyclerView
+
+ override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
+ if (!AppConfig.disableMangaScaling) {
+ scaleDetector.onTouchEvent(ev)
+ flingDetector.onTouchEvent(ev)
+ val recyclerRect = Rect()
+ recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev)
+ recyclerRect.inset(1, 1)
+
+ if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) {
+ return super.dispatchTouchEvent(ev)
+ }
+
+ ev.setLocation(
+ ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()),
+ ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat()),
+ )
+ }
+ return super.dispatchTouchEvent(ev)
+ }
+
+ inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
+ recycler?.onScaleBegin()
+ return true
+ }
+
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
+ recycler?.onScale(detector.scaleFactor)
+ return true
+ }
+
+ override fun onScaleEnd(detector: ScaleGestureDetector) {
+ recycler?.onScaleEnd()
+ }
+ }
+
+ inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
+ override fun onDown(e: MotionEvent): Boolean {
+ return true
+ }
+
+ override fun onFling(
+ e1: MotionEvent?,
+ e2: MotionEvent,
+ velocityX: Float,
+ velocityY: Float,
+ ): Boolean {
+ return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt
new file mode 100644
index 000000000..c7cac1235
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt
@@ -0,0 +1,390 @@
+package io.legado.app.ui.book.manga.rv
+
+import android.animation.AnimatorSet
+import android.animation.ValueAnimator
+import android.content.Context
+import android.graphics.RectF
+import android.util.AttributeSet
+import android.view.HapticFeedbackConstants
+import android.view.MotionEvent
+import android.view.ViewConfiguration
+import android.view.animation.DecelerateInterpolator
+import androidx.core.animation.doOnEnd
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import io.legado.app.help.config.AppConfig
+import io.legado.app.utils.findCenterViewPosition
+import kotlin.math.abs
+
+
+class WebtoonRecyclerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyle: Int = 0,
+) : RecyclerView(context, attrs, defStyle) {
+
+ private var isZooming = false
+ private var atLastPosition = false
+ private var atFirstPosition = false
+ private var halfWidth = 0
+ private var halfHeight = 0
+ private var originalHeight = 0
+ private var heightSet = false
+ private var firstVisibleItemPosition = 0
+ private var lastVisibleItemPosition = 0
+ private var currentScale = DEFAULT_RATE
+ private var mLastCenterViewPosition = 0
+
+ private var mPreScrollListener: IComicPreScroll? = null
+ private var mNestedPreScrollListener: IComicPreScroll? = null
+ private val listener = GestureListener()
+ private val detector = Detector()
+ private val mcRect = RectF()
+ private var mToucheMiddle: (() -> Unit)? = null
+
+ //起始点
+ private var startX: Float = 0f
+ private var startY: Float = 0f
+
+ var doubleTapZoom = true
+
+ var tapListener: ((MotionEvent) -> Unit)? = null
+ var longTapListener: ((MotionEvent) -> Boolean)? = null
+ fun onToucheMiddle(init: () -> Unit) = apply { this.mToucheMiddle = init }
+
+ override fun onMeasure(widthSpec: Int, heightSpec: Int) {
+ halfWidth = MeasureSpec.getSize(widthSpec) / 2
+ halfHeight = MeasureSpec.getSize(heightSpec) / 2
+ if (!heightSet) {
+ originalHeight = MeasureSpec.getSize(heightSpec)
+ heightSet = true
+ }
+ super.onMeasure(widthSpec, heightSpec)
+ }
+
+ override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
+ detector.onTouchEvent(ev!!)
+ return super.dispatchTouchEvent(ev)
+ }
+
+ override fun onScrolled(dx: Int, dy: Int) {
+ super.onScrolled(dx, dy)
+ val layoutManager = layoutManager
+ lastVisibleItemPosition =
+ (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
+ firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+ }
+
+ override fun onScrollStateChanged(state: Int) {
+ super.onScrollStateChanged(state)
+ val layoutManager = layoutManager
+ val visibleItemCount = layoutManager?.childCount ?: 0
+ val totalItemCount = layoutManager?.itemCount ?: 0
+ atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
+ atFirstPosition = firstVisibleItemPosition == 0
+ }
+
+ override fun dispatchNestedPreScroll(
+ dx: Int,
+ dy: Int,
+ consumed: IntArray?,
+ offsetInWindow: IntArray?,
+ type: Int
+ ): Boolean {
+ val position = findCenterViewPosition()
+ if (position != NO_POSITION && position != mLastCenterViewPosition) {
+ mLastCenterViewPosition = position
+ mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
+ }
+ mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
+ return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ mcRect.set(width * 0.33f, height * 0.33f, width * 0.66f, height * 0.66f)
+ }
+
+ private fun getPositionX(positionX: Float): Float {
+ if (currentScale < 1) {
+ return 0f
+ }
+ val maxPositionX = halfWidth * (currentScale - 1)
+ return positionX.coerceIn(-maxPositionX, maxPositionX)
+ }
+
+ private fun getPositionY(positionY: Float): Float {
+ if (currentScale < 1) {
+ return (originalHeight / 2 - halfHeight).toFloat()
+ }
+ val maxPositionY = halfHeight * (currentScale - 1)
+ return positionY.coerceIn(-maxPositionY, maxPositionY)
+ }
+
+ private fun zoom(
+ fromRate: Float,
+ toRate: Float,
+ fromX: Float,
+ toX: Float,
+ fromY: Float,
+ toY: Float,
+ ) {
+ isZooming = true
+ val animatorSet = AnimatorSet()
+ val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
+ translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
+
+ val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
+ translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
+
+ val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
+ scaleAnimator.addUpdateListener { animation ->
+ currentScale = animation.animatedValue as Float
+ setScaleRate(currentScale)
+ }
+ animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
+ animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
+ animatorSet.interpolator = DecelerateInterpolator()
+ animatorSet.start()
+ animatorSet.doOnEnd {
+ isZooming = false
+ currentScale = toRate
+ }
+ }
+
+ fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
+ if (currentScale <= 1f) return false
+
+ val distanceTimeFactor = 0.4f
+ val animatorSet = AnimatorSet()
+
+ if (velocityX != 0) {
+ val dx = (distanceTimeFactor * velocityX / 2)
+ val newX = getPositionX(x + dx)
+ val translationXAnimator = ValueAnimator.ofFloat(x, newX)
+ translationXAnimator.addUpdateListener { animation ->
+ x = getPositionX(animation.animatedValue as Float)
+ }
+ animatorSet.play(translationXAnimator)
+ }
+ if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
+ val dy = (distanceTimeFactor * velocityY / 2)
+ val newY = getPositionY(y + dy)
+ val translationYAnimator = ValueAnimator.ofFloat(y, newY)
+ translationYAnimator.addUpdateListener { animation ->
+ y = getPositionY(animation.animatedValue as Float)
+ }
+ animatorSet.play(translationYAnimator)
+ }
+
+ animatorSet.duration = 400
+ animatorSet.interpolator = DecelerateInterpolator()
+ animatorSet.start()
+
+ return true
+ }
+
+ private fun zoomScrollBy(dx: Int, dy: Int) {
+ if (dx != 0) {
+ x = getPositionX(x + dx)
+ }
+ if (dy != 0) {
+ y = getPositionY(y + dy)
+ }
+ }
+
+ private fun setScaleRate(rate: Float) {
+ scaleX = rate
+ scaleY = rate
+ }
+
+ fun onScale(scaleFactor: Float) {
+ currentScale *= scaleFactor
+ currentScale = currentScale.coerceIn(
+ MIN_RATE,
+ MAX_SCALE_RATE,
+ )
+
+ setScaleRate(currentScale)
+
+ layoutParams.height = if (currentScale < 1) {
+ (originalHeight / currentScale).toInt()
+ } else {
+ originalHeight
+ }
+ halfHeight = layoutParams.height / 2
+
+ if (currentScale != DEFAULT_RATE) {
+ x = getPositionX(x)
+ y = getPositionY(y)
+ } else {
+ x = 0f
+ y = 0f
+ }
+
+ requestLayout()
+ }
+
+ fun onScaleBegin() {
+ if (detector.isDoubleTapping) {
+ detector.isQuickScaling = true
+ }
+ }
+
+ fun onScaleEnd() {
+ if (scaleX < MIN_RATE) {
+ zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
+ }
+ }
+
+ inner class GestureListener : GestureDetectorWithLongTap.Listener() {
+
+ override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
+ if (mcRect.contains(startX, startY)) {
+ mToucheMiddle?.invoke()
+ } else {
+ tapListener?.invoke(ev)
+ }
+ return false
+ }
+
+ override fun onDoubleTap(ev: MotionEvent): Boolean {
+ detector.isDoubleTapping = true
+ return false
+ }
+
+ fun onDoubleTapConfirmed(ev: MotionEvent) {
+ if (!isZooming && doubleTapZoom) {
+ if (scaleX != DEFAULT_RATE) {
+ zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
+ } else {
+ val toScale = 2f
+ val toX = (halfWidth - ev.x) * (toScale - 1)
+ val toY = (halfHeight - ev.y) * (toScale - 1)
+ zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
+ }
+ }
+ }
+
+ override fun onLongTapConfirmed(ev: MotionEvent) {
+ if (longTapListener?.invoke(ev) == true) {
+ performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
+ }
+ }
+ }
+
+ inner class Detector : GestureDetectorWithLongTap(context, listener) {
+
+ private var scrollPointerId = 0
+ private var downX = 0
+ private var downY = 0
+ private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
+ private var isZoomDragging = false
+ var isDoubleTapping = false
+ var isQuickScaling = false
+ val disableMangaScaling = AppConfig.disableMangaScaling
+ override fun onTouchEvent(ev: MotionEvent): Boolean {
+ val action = ev.actionMasked
+ val actionIndex = ev.actionIndex
+
+ when (action) {
+ MotionEvent.ACTION_DOWN -> {
+ scrollPointerId = ev.getPointerId(0)
+ val eveX = (ev.x + 0.5f)
+ val eveY = (ev.y + 0.5f)
+ downX = eveX.toInt()
+ downY = eveY.toInt()
+ startX = eveX
+ startY = eveY
+ }
+
+ MotionEvent.ACTION_POINTER_DOWN -> {
+ scrollPointerId = ev.getPointerId(actionIndex)
+ downX = (ev.getX(actionIndex) + 0.5f).toInt()
+ downY = (ev.getY(actionIndex) + 0.5f).toInt()
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ if (disableMangaScaling) {
+ return false
+ }
+ if (isDoubleTapping && isQuickScaling) {
+ return true
+ }
+
+ val index = ev.findPointerIndex(scrollPointerId)
+ if (index < 0) {
+ return false
+ }
+
+ val x = (ev.getX(index) + 0.5f).toInt()
+ val y = (ev.getY(index) + 0.5f).toInt()
+ var dx = x - downX
+ var dy = if (atFirstPosition || atLastPosition) y - downY else 0
+
+ if (!isZoomDragging && currentScale > 1f) {
+ var startScroll = false
+
+ if (abs(dx) > touchSlop) {
+ if (dx < 0) {
+ dx += touchSlop
+ } else {
+ dx -= touchSlop
+ }
+ startScroll = true
+ }
+ if (abs(dy) > touchSlop) {
+ if (dy < 0) {
+ dy += touchSlop
+ } else {
+ dy -= touchSlop
+ }
+ startScroll = true
+ }
+
+ if (startScroll) {
+ isZoomDragging = true
+ }
+ }
+
+ if (isZoomDragging) {
+ zoomScrollBy(dx, dy)
+ }
+ }
+
+ MotionEvent.ACTION_UP -> {
+ if (isDoubleTapping && !isQuickScaling && !disableMangaScaling) {
+ listener.onDoubleTapConfirmed(ev)
+ }
+ isZoomDragging = false
+ isDoubleTapping = false
+ isQuickScaling = false
+ }
+
+ MotionEvent.ACTION_CANCEL -> {
+ isZoomDragging = false
+ isDoubleTapping = false
+ isQuickScaling = false
+ }
+ }
+ return super.onTouchEvent(ev)
+ }
+ }
+
+ fun setPreScrollListener(iComicPreScroll: IComicPreScroll) {
+ mPreScrollListener = iComicPreScroll
+ }
+
+ fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) {
+ mNestedPreScrollListener = iComicPreScroll
+ }
+
+ fun interface IComicPreScroll {
+ fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int)
+ }
+}
+
+private const val ANIMATOR_DURATION_TIME = 200
+private const val MIN_RATE = 0.5f
+private const val DEFAULT_RATE = 1f
+private const val MAX_SCALE_RATE = 3f
diff --git a/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
new file mode 100644
index 000000000..0c8580821
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
@@ -0,0 +1,236 @@
+package io.legado.app.ui.book.read
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.graphics.Color
+import android.graphics.drawable.GradientDrawable
+import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.View.OnClickListener
+import android.view.View.OnLongClickListener
+import android.view.animation.Animation
+import android.widget.FrameLayout
+import androidx.core.view.isGone
+import androidx.core.view.isVisible
+import io.legado.app.R
+import io.legado.app.databinding.ViewMangaMenuBinding
+import io.legado.app.help.IntentData
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.lib.dialogs.alert
+import io.legado.app.lib.theme.bottomBackground
+import io.legado.app.lib.theme.getPrimaryTextColor
+import io.legado.app.lib.theme.primaryColor
+import io.legado.app.lib.theme.primaryTextColor
+import io.legado.app.model.ReadMange
+import io.legado.app.ui.browser.WebViewActivity
+import io.legado.app.utils.ColorUtils
+import io.legado.app.utils.ConstraintModify
+import io.legado.app.utils.activity
+import io.legado.app.utils.applyNavigationBarPadding
+import io.legado.app.utils.dpToPx
+import io.legado.app.utils.gone
+import io.legado.app.utils.invisible
+import io.legado.app.utils.loadAnimation
+import io.legado.app.utils.modifyBegin
+import io.legado.app.utils.openUrl
+import io.legado.app.utils.startActivity
+import io.legado.app.utils.visible
+
+class MangaMenu @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+) : FrameLayout(context, attrs) {
+ private val binding = ViewMangaMenuBinding.inflate(LayoutInflater.from(context), this, true)
+ private val callBack: CallBack get() = activity as CallBack
+ var canShowMenu: Boolean = false
+ private val menuTopIn: Animation by lazy {
+ loadAnimation(context, R.anim.anim_readbook_top_in)
+ }
+ private val menuTopOut: Animation by lazy {
+ loadAnimation(context, R.anim.anim_readbook_top_out)
+ }
+ private val menuBottomIn: Animation by lazy {
+ loadAnimation(context, R.anim.anim_readbook_bottom_in)
+ }
+ private val menuBottomOut: Animation by lazy {
+ loadAnimation(context, R.anim.anim_readbook_bottom_out)
+ }
+ private var isMenuOutAnimating = false
+ private val immersiveMenu: Boolean
+ get() = AppConfig.readBarStyleFollowPage && ReadBookConfig.durConfig.curBgType() == 0
+
+ private var bgColor: Int = if (immersiveMenu) {
+ kotlin.runCatching {
+ Color.parseColor(ReadBookConfig.durConfig.curBgStr())
+ }.getOrDefault(context.bottomBackground)
+ } else {
+ context.bottomBackground
+ }
+
+ private var textColor: Int = if (immersiveMenu) {
+ ReadBookConfig.durConfig.curTextColor()
+ } else {
+ context.getPrimaryTextColor(ColorUtils.isColorLight(bgColor))
+ }
+
+ private val menuOutListener = object : Animation.AnimationListener {
+ override fun onAnimationStart(animation: Animation) {
+ isMenuOutAnimating = true
+ binding.vwMenuBg.setOnClickListener(null)
+ }
+
+ override fun onAnimationEnd(animation: Animation) {
+ binding.titleBar.invisible()
+ isMenuOutAnimating = false
+ canShowMenu = false
+ callBack.upSystemUiVisibility(false)
+ }
+
+ override fun onAnimationRepeat(animation: Animation) = Unit
+ }
+ private val menuInListener = object : Animation.AnimationListener {
+ override fun onAnimationStart(animation: Animation) {
+ binding.tvSourceAction.text =
+ ReadMange.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
+ callBack.upSystemUiVisibility(true)
+ binding.tvSourceAction.isGone = false
+ }
+
+ @SuppressLint("RtlHardcoded")
+ override fun onAnimationEnd(animation: Animation) {
+ binding.run {
+ vwMenuBg.setOnClickListener { runMenuOut() }
+ }
+ }
+
+ override fun onAnimationRepeat(animation: Animation) = Unit
+ }
+
+ init {
+ initView()
+ bindEvent()
+ }
+
+ private fun initView(reset: Boolean = false) = binding.run {
+ initAnimation()
+ if (immersiveMenu) {
+ val lightTextColor = ColorUtils.withAlpha(ColorUtils.lightenColor(textColor), 0.75f)
+ titleBar.setTextColor(textColor)
+ titleBar.setBackgroundColor(bgColor)
+ titleBar.setColorFilter(textColor)
+ tvChapterName.setTextColor(lightTextColor)
+ tvChapterUrl.setTextColor(lightTextColor)
+ } else if (reset) {
+ val bgColor = context.primaryColor
+ val textColor = context.primaryTextColor
+ titleBar.setTextColor(textColor)
+ titleBar.setBackgroundColor(bgColor)
+ titleBar.setColorFilter(textColor)
+ tvChapterName.setTextColor(textColor)
+ tvChapterUrl.setTextColor(textColor)
+ }
+ val brightnessBackground = GradientDrawable()
+ brightnessBackground.cornerRadius = 5F.dpToPx()
+ brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f))
+ if (AppConfig.isEInkMode) {
+ titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
+ }
+ if (AppConfig.showReadTitleBarAddition) {
+ titleBarAddition.visible()
+ } else {
+ titleBarAddition.gone()
+ }
+ upBrightnessVwPos()
+ /**
+ * 确保视图不被导航栏遮挡
+ */
+ applyNavigationBarPadding()
+ }
+
+ private fun upBrightnessVwPos() {
+ if (AppConfig.brightnessVwPos) {
+ binding.root.modifyBegin()
+ .clear(R.id.ll_brightness, ConstraintModify.Anchor.LEFT)
+ .rightToRightOf(R.id.ll_brightness, R.id.vw_menu_root)
+ .commit()
+ } else {
+ binding.root.modifyBegin()
+ .clear(R.id.ll_brightness, ConstraintModify.Anchor.RIGHT)
+ .leftToLeftOf(R.id.ll_brightness, R.id.vw_menu_root)
+ .commit()
+ }
+ }
+
+ private fun initAnimation() {
+ menuTopIn.setAnimationListener(menuInListener)
+ menuTopOut.setAnimationListener(menuOutListener)
+ }
+
+ fun runMenuOut(anim: Boolean = !AppConfig.isEInkMode) {
+ if (isMenuOutAnimating) {
+ return
+ }
+ if (this.isVisible) {
+ if (anim) {
+ binding.titleBar.startAnimation(menuTopOut)
+ } else {
+ menuOutListener.onAnimationStart(menuBottomOut)
+ menuOutListener.onAnimationEnd(menuBottomOut)
+ }
+ }
+ }
+
+ fun runMenuIn(anim: Boolean = !AppConfig.isEInkMode) {
+ this.visible()
+ binding.titleBar.visible()
+ if (anim) {
+ binding.titleBar.startAnimation(menuTopIn)
+ } else {
+ menuInListener.onAnimationStart(menuBottomIn)
+ menuInListener.onAnimationEnd(menuBottomIn)
+ }
+ }
+
+
+ private fun bindEvent() = binding.run {
+ vwMenuBg.setOnClickListener { runMenuOut() }
+ titleBar.toolbar.setOnClickListener {
+ callBack.openBookInfoActivity()
+ }
+ val chapterViewClickListener = OnClickListener {
+ if (AppConfig.readUrlInBrowser) {
+ context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
+ } else {
+ context.startActivity {
+ val url = tvChapterUrl.text.toString()
+ putExtra("title", tvChapterName.text)
+ putExtra("url", url)
+ IntentData.put(url, ReadMange.bookSource?.getHeaderMap(true))
+ }
+ }
+ }
+ val chapterViewLongClickListener = OnLongClickListener {
+ context.alert(R.string.open_fun) {
+ setMessage(R.string.use_browser_open)
+ okButton {
+ AppConfig.readUrlInBrowser = true
+ }
+ noButton {
+ AppConfig.readUrlInBrowser = false
+ }
+ }
+ true
+ }
+ tvChapterName.setOnClickListener(chapterViewClickListener)
+ tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
+ tvChapterUrl.setOnClickListener(chapterViewClickListener)
+ tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener)
+ }
+
+ interface CallBack {
+ fun openBookInfoActivity()
+ fun upSystemUiVisibility(value:Boolean)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt
index af9a88ce8..bf7fb051a 100644
--- a/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt
+++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt
@@ -28,6 +28,7 @@ import io.legado.app.lib.theme.accentColor
import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.info.BookInfoActivity
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.main.MainViewModel
import io.legado.app.utils.cnCompare
@@ -35,6 +36,7 @@ import io.legado.app.utils.flowWithLifecycleAndDatabaseChangeFirst
import io.legado.app.utils.observeEvent
import io.legado.app.utils.setEdgeEffectColor
import io.legado.app.utils.startActivity
+import io.legado.app.utils.startReadOrMangaActivity
import io.legado.app.utils.viewbindingdelegate.viewBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -247,7 +249,7 @@ class BooksFragment() : BaseFragment(R.layout.fragment_books),
putExtra("bookUrl", book.bookUrl)
}
- else -> startActivity {
+ else -> startReadOrMangaActivity(book) {
putExtra("bookUrl", book.bookUrl)
}
}
diff --git a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt
index 6df5eab71..24dae4285 100644
--- a/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt
+++ b/app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt
@@ -23,6 +23,7 @@ import io.legado.app.lib.theme.primaryColor
import io.legado.app.ui.book.audio.AudioPlayActivity
import io.legado.app.ui.book.group.GroupEditDialog
import io.legado.app.ui.book.info.BookInfoActivity
+import io.legado.app.ui.book.manga.ReadMangaActivity
import io.legado.app.ui.book.read.ReadBookActivity
import io.legado.app.ui.book.search.SearchActivity
import io.legado.app.ui.main.bookshelf.BaseBookshelfFragment
@@ -203,7 +204,7 @@ class BookshelfFragment2() : BaseBookshelfFragment(R.layout.fragment_bookshelf2)
startActivity {
putExtra("bookUrl", item.bookUrl)
}
- else -> startActivity {
+ else -> startReadOrMangaActivity(item) {
putExtra("bookUrl", item.bookUrl)
}
}
diff --git a/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt b/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt
new file mode 100644
index 000000000..37df004dc
--- /dev/null
+++ b/app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt
@@ -0,0 +1,205 @@
+package io.legado.app.ui.widget
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.view.View
+import android.view.WindowInsets
+import androidx.annotation.AttrRes
+import androidx.core.content.ContextCompat
+import androidx.core.content.res.use
+import androidx.core.graphics.ColorUtils
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import io.legado.app.R
+import splitties.dimensions.dp
+import java.text.DecimalFormat
+import java.text.NumberFormat
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import com.google.android.material.R as materialR
+
+class ReaderInfoBarView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ @AttrRes defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr) {
+
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ private val textBounds = Rect()
+ private val timeFormat = SimpleDateFormat.getTimeInstance(SimpleDateFormat.SHORT)
+ private val timeReceiver = TimeReceiver()
+ private var insetLeft: Int = 0
+ private var insetRight: Int = 0
+ private var insetTop: Int = 0
+ private var cutoutInsetLeft = 0
+ private var cutoutInsetRight = 0
+ private val colorText = ColorUtils.setAlphaComponent(
+ context.obtainStyledAttributes(intArrayOf(materialR.attr.colorOnSurface)).use {
+ it.getColor(0, Color.BLACK)
+ },
+ 200,
+ )
+ private val colorOutline = ColorUtils.setAlphaComponent(
+ context.obtainStyledAttributes(intArrayOf(materialR.attr.colorSurface)).use {
+ it.getColor(0, Color.WHITE)
+ },
+ 200,
+ )
+
+ private var timeText = timeFormat.format(Date())
+ private var text: String = ""
+
+ private val innerHeight
+ get() = height - paddingTop - paddingBottom - insetTop
+
+ private val innerWidth
+ get() = width - paddingLeft - paddingRight - insetLeft - insetRight
+
+ init {
+ val insetStart = dp(10f).toInt()
+ val insetEnd = dp(10f).toInt()
+ paint.strokeWidth = dp(2f)
+ paint.setShadowLayer(2f, 1f, 1f, Color.GRAY)
+ insetLeft = insetStart
+ insetRight = insetEnd
+ insetTop = minOf(insetLeft, insetRight)
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val ty = innerHeight / 2f + textBounds.height() / 2f - textBounds.bottom
+ paint.textAlign = Paint.Align.LEFT
+ canvas.drawTextOutline(
+ text,
+ (paddingLeft + insetLeft + cutoutInsetLeft).toFloat(),
+ paddingTop + insetTop + ty,
+ )
+ paint.textAlign = Paint.Align.RIGHT
+ canvas.drawTextOutline(
+ timeText,
+ (width - paddingRight - insetRight - cutoutInsetRight).toFloat(),
+ paddingTop + insetTop + ty,
+ )
+ }
+
+ override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
+ super.onSizeChanged(w, h, oldw, oldh)
+ updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
+ updateTextSize()
+ }
+
+ override fun onApplyWindowInsets(insets: WindowInsets): WindowInsets {
+ updateCutoutInsets(WindowInsetsCompat.toWindowInsetsCompat(insets))
+ return super.onApplyWindowInsets(insets)
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ ContextCompat.registerReceiver(
+ context,
+ timeReceiver,
+ IntentFilter(Intent.ACTION_TIME_TICK),
+ ContextCompat.RECEIVER_EXPORTED,
+ )
+ updateCutoutInsets(ViewCompat.getRootWindowInsets(this))
+ }
+
+ override fun onDetachedFromWindow() {
+ super.onDetachedFromWindow()
+ context.unregisterReceiver(timeReceiver)
+ }
+
+ fun update(
+ currentPage: Int,
+ totalPage: Int,
+ percent: Float,
+ chapterIndex: Int,
+ chapterCount: Int
+ ) {
+ text = context.getString(
+ R.string.book_reader_info_bar,
+ chapterIndex,
+ chapterCount,
+ currentPage,
+ totalPage,
+ if (percent in 0f..1f) (percent * 100).format() else ""
+ )
+ updateTextSize()
+ invalidate()
+ }
+
+ private fun Number.format(
+ decimals: Int = 0,
+ decPoint: Char = '.',
+ thousandsSep: Char? = ' '
+ ): String {
+ val formatter = NumberFormat.getInstance(Locale.US) as DecimalFormat
+ val symbols = formatter.decimalFormatSymbols
+ if (thousandsSep != null) {
+ symbols.groupingSeparator = thousandsSep
+ formatter.isGroupingUsed = true
+ } else {
+ formatter.isGroupingUsed = false
+ }
+ symbols.decimalSeparator = decPoint
+ formatter.decimalFormatSymbols = symbols
+ formatter.minimumFractionDigits = decimals
+ formatter.maximumFractionDigits = decimals
+ return when (this) {
+ is Float,
+ is Double,
+ -> formatter.format(this.toDouble())
+
+ else -> formatter.format(this.toLong())
+ }
+ }
+
+
+ private fun updateTextSize() {
+ val str = text + timeText
+ val testTextSize = 48f
+ paint.textSize = testTextSize
+ paint.getTextBounds(str, 0, str.length, textBounds)
+ paint.textSize = testTextSize * innerHeight / textBounds.height()
+ paint.getTextBounds(str, 0, str.length, textBounds)
+ }
+
+ private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) {
+ paint.color = colorOutline
+ paint.style = Paint.Style.STROKE
+ drawText(text, x, y, paint)
+ paint.color = colorText
+ paint.style = Paint.Style.FILL
+ drawText(text, x, y, paint)
+ }
+
+ private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) {
+ val cutouts = (insetsCompat ?: return).displayCutout?.boundingRects.orEmpty()
+ cutoutInsetLeft = 0
+ cutoutInsetRight = 0
+ for (rect in cutouts) {
+ if (rect.left <= paddingLeft) {
+ cutoutInsetLeft += rect.width()
+ }
+ if (rect.right >= width - paddingRight) {
+ cutoutInsetRight += rect.width()
+ }
+ }
+ }
+
+ private inner class TimeReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ timeText = timeFormat.format(Date())
+ invalidate()
+ }
+ }
+}
diff --git a/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt
index 53f20f0c2..d13e034b9 100644
--- a/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt
+++ b/app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt
@@ -70,14 +70,18 @@ class LoadMoreView(context: Context, attrs: AttributeSet? = null) : FrameLayout(
binding.tvText.visible()
}
- fun error(msg: String) {
+ fun error(msg: String?, text: String = "") {
stopLoad()
hasMore = false
- errorMsg = msg
- binding.tvText.text = context.getString(R.string.error_load_msg, "点击查看详情")
+ errorMsg = msg ?: ""
+ binding.tvText.text =
+ text.ifEmpty { context.getString(R.string.error_load_msg, "点击查看详情") }
binding.tvText.visible()
}
+ fun getLoading() = binding.rotateLoading
+ fun getLoadingText() = binding.tvText
+
private fun showErrorDialog(): Boolean {
if (errorMsg.isBlank()) {
return false
diff --git a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt
index 72552b5b7..51a092ed4 100644
--- a/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt
+++ b/app/src/main/java/io/legado/app/utils/ActivityExtensions.kt
@@ -18,6 +18,7 @@ import androidx.annotation.ColorInt
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
import androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
import androidx.fragment.app.DialogFragment
@@ -243,4 +244,9 @@ val Activity.navigationBarGravity: Int
fun AppCompatActivity.showHelp(fileName: String) {
val mdText = String(assets.open("web/help/md/${fileName}.md").readBytes())
showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD))
+}
+
+fun immersionFullScreen(windowInsetsControllerCompat: WindowInsetsControllerCompat) {
+ windowInsetsControllerCompat.systemBarsBehavior = BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ windowInsetsControllerCompat.hide(WindowInsetsCompat.Type.systemBars())
}
\ No newline at end of file
diff --git a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt
index 94840a39b..874e49654 100644
--- a/app/src/main/java/io/legado/app/utils/ContextExtensions.kt
+++ b/app/src/main/java/io/legado/app/utils/ContextExtensions.kt
@@ -38,7 +38,10 @@ import androidx.preference.PreferenceManager
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import io.legado.app.R
import io.legado.app.constant.AppConst
+import io.legado.app.data.entities.Book
import io.legado.app.help.IntentHelp
+import io.legado.app.help.book.isImage
+import io.legado.app.help.config.AppConfig
import splitties.systemservices.clipboardManager
import splitties.systemservices.connectivityManager
import splitties.systemservices.uiModeManager
@@ -53,6 +56,18 @@ inline fun Context.startActivity(configIntent: Intent.()
startActivity(intent)
}
+inline fun Context.startReadOrMangaActivity(
+ book: Book,
+ configIntent: Intent.() -> Unit = {},
+) {
+ val intent =
+ Intent(this, if (book.isImage && AppConfig.showMangaUi) M::class.java else A::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.apply(configIntent)
+ startActivity(intent)
+}
+
+
inline fun Context.startService(configIntent: Intent.() -> Unit = {}) {
startService(Intent(this, T::class.java).apply(configIntent))
}
@@ -65,7 +80,7 @@ inline fun Context.stopService() {
inline fun Context.servicePendingIntent(
action: String,
requestCode: Int = 0,
- configIntent: Intent.() -> Unit = {}
+ configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -81,7 +96,7 @@ inline fun Context.servicePendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
fun Context.activityPendingIntent(
intent: Intent,
- action: String
+ action: String,
): PendingIntent? {
intent.action = action
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -95,7 +110,7 @@ fun Context.activityPendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
inline fun Context.activityPendingIntent(
action: String,
- configIntent: Intent.() -> Unit = {}
+ configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -111,7 +126,7 @@ inline fun Context.activityPendingIntent(
@SuppressLint("UnspecifiedImmutableFlag")
inline fun Context.broadcastPendingIntent(
action: String,
- configIntent: Intent.() -> Unit = {}
+ configIntent: Intent.() -> Unit = {},
): PendingIntent? {
val intent = Intent(this, T::class.java)
intent.action = action
@@ -161,7 +176,7 @@ fun Context.putPrefString(key: String, value: String?) =
fun Context.getPrefStringSet(
key: String,
- defValue: MutableSet? = null
+ defValue: MutableSet? = null,
): MutableSet? = defaultSharedPreferences.getStringSet(key, defValue)
fun Context.putPrefStringSet(key: String, value: MutableSet) =
@@ -255,7 +270,7 @@ fun Context.share(file: File, type: String = "text/*") {
fun Context.shareWithQr(
text: String,
title: String = getString(R.string.share),
- errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H
+ errorCorrectionLevel: ErrorCorrectionLevel = ErrorCorrectionLevel.H,
) {
val bitmap = QRCodeUtils.createQRCode(text, errorCorrectionLevel = errorCorrectionLevel)
if (bitmap == null) {
diff --git a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt
index 432d0690f..9a143d5cc 100644
--- a/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt
+++ b/app/src/main/java/io/legado/app/utils/FragmentExtensions.kt
@@ -14,6 +14,9 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import io.legado.app.R
+import io.legado.app.data.entities.Book
+import io.legado.app.help.book.isImage
+import io.legado.app.help.config.AppConfig
import io.legado.app.ui.widget.dialog.TextDialog
inline fun Fragment.showDialogFragment(
@@ -81,6 +84,19 @@ inline fun Fragment.startActivity(
startActivity(Intent(requireContext(), T::class.java).apply(configIntent))
}
+inline fun Fragment.startReadOrMangaActivity(
+ book: Book,
+ configIntent: Intent.() -> Unit = {},
+) {
+ val intent = Intent(
+ requireActivity(),
+ if (book.isImage && AppConfig.showMangaUi) M::class.java else A::class.java
+ )
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ intent.apply(configIntent)
+ startActivity(intent)
+}
+
fun Fragment.showHelp(fileName: String) {
val mdText = String(requireContext().assets.open("web/help/md/${fileName}.md").readBytes())
showDialogFragment(TextDialog(getString(R.string.help), mdText, TextDialog.Mode.MD))
diff --git a/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt b/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt
new file mode 100644
index 000000000..5a364992c
--- /dev/null
+++ b/app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt
@@ -0,0 +1,28 @@
+package io.legado.app.utils
+
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+
+fun RecyclerView.findCenterViewPosition(): Int {
+ return getChildAdapterPosition(findChildViewUnder(width / 2f, height / 2f) ?: return RecyclerView.NO_POSITION)
+}
+
+fun RecyclerView.findViewPosition(x: Float, y: Float): Int {
+ return getChildAdapterPosition(findChildViewUnder(x, y) ?: return RecyclerView.NO_POSITION)
+}
+
+fun RecyclerView.findFirstVisibleViewPosition(): Int {
+ var pos = -1
+ if (layoutManager is LinearLayoutManager) {
+ pos = (layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
+ }
+ return pos
+}
+
+fun RecyclerView.findLastVisibleViewPosition(): Int {
+ var pos = -1
+ if ( layoutManager is LinearLayoutManager) {
+ pos = (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
+ }
+ return pos
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_mange.xml b/app/src/main/res/layout/activity_mange.xml
new file mode 100644
index 000000000..626c749c0
--- /dev/null
+++ b/app/src/main/res/layout/activity_mange.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/book_comic_loading_rv.xml b/app/src/main/res/layout/book_comic_loading_rv.xml
new file mode 100644
index 000000000..d96df3616
--- /dev/null
+++ b/app/src/main/res/layout/book_comic_loading_rv.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/book_comic_rv.xml b/app/src/main/res/layout/book_comic_rv.xml
new file mode 100644
index 000000000..93912470d
--- /dev/null
+++ b/app/src/main/res/layout/book_comic_rv.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/view_manga_menu.xml b/app/src/main/res/layout/view_manga_menu.xml
new file mode 100644
index 000000000..e8be9ad07
--- /dev/null
+++ b/app/src/main/res/layout/view_manga_menu.xml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/book_manga.xml b/app/src/main/res/menu/book_manga.xml
new file mode 100644
index 000000000..7075ef977
--- /dev/null
+++ b/app/src/main/res/menu/book_manga.xml
@@ -0,0 +1,21 @@
+
diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml
index 32521b852..69b72f5d9 100644
--- a/app/src/main/res/values-es-rES/strings.xml
+++ b/app/src/main/res/values-es-rES/strings.xml
@@ -1180,6 +1180,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
Descargar el siguiente capítulo
Descargar todos los capítulos
diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml
index 98b588910..26609bbc9 100644
--- a/app/src/main/res/values-ja-rJP/strings.xml
+++ b/app/src/main/res/values-ja-rJP/strings.xml
@@ -1183,6 +1183,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
下载之后章节
下载全部章节
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index d370da86c..9bf70d57a 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1183,6 +1183,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
Faça o download do próximo capítulo
Download de todos os capítulos
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index 8aade9fa6..651c4f391 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -1179,6 +1179,9 @@ Còn
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
Tải xuống chương tiếp theo
Tải xuống tất cả các chương
diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml
index 2d95a84d8..5f08a7fff 100644
--- a/app/src/main/res/values-zh-rHK/strings.xml
+++ b/app/src/main/res/values-zh-rHK/strings.xml
@@ -1180,6 +1180,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
下載之後章節
下載全部章節
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 15a21e71c..10f2955c0 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1182,6 +1182,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
下載之後章節
下載全部章節
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 97f4191d4..360d0e4a0 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1182,6 +1182,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
下载之后章节
下载全部章节
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 81726a580..666b77faf 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -111,4 +111,6 @@
#FF424242
#202020
+
+ #141414
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a67ff259a..d1c750d49 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1183,6 +1183,9 @@
通过耳机按键来启动朗读
显示重复书源
主题配置
+ 漫画浏览
+ 禁用漫画缩放
+ 页数. %1$d/%2$d 章节. %3$d/%4$d -> %5$s%%
Download the chapter after
Download all chapter
diff --git a/app/src/main/res/xml/pref_config_other.xml b/app/src/main/res/xml/pref_config_other.xml
index 75028222b..8c1f5a43b 100644
--- a/app/src/main/res/xml/pref_config_other.xml
+++ b/app/src/main/res/xml/pref_config_other.xml
@@ -42,6 +42,18 @@
android:title="@string/show_rss"
app:iconSpaceReserved="false" />
+
+
+
+