Explorar o código

重做漫画功能,不影响现有逻辑漫画功能 (#4685)

* 增加漫画UI

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* 完成加载图片人机验证问题

* 完成加载框

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* 剔除重复章节

* ...

* ....

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ....

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...

* ...
lhjgege hai 1 ano
pai
achega
a6300ebdf8
Modificáronse 58 ficheiros con 3373 adicións e 49 borrados
  1. 3 0
      app/build.gradle
  2. 16 0
      app/src/main/AndroidManifest.xml
  3. 1 1
      app/src/main/assets/18PlusList.txt
  4. 1 1
      app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt
  5. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  6. 8 0
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  7. 21 1
      app/src/main/java/io/legado/app/help/glide/LegadoGlideModule.kt
  8. 2 1
      app/src/main/java/io/legado/app/help/glide/OkHttpModeLoaderFactory.kt
  9. 6 3
      app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt
  10. 24 4
      app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt
  11. 3 0
      app/src/main/java/io/legado/app/help/glide/progress/OnProgressListener.kt
  12. 51 0
      app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt
  13. 54 0
      app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt
  14. 12 1
      app/src/main/java/io/legado/app/help/http/HttpHelper.kt
  15. 34 3
      app/src/main/java/io/legado/app/model/BookCover.kt
  16. 638 0
      app/src/main/java/io/legado/app/model/ReadMange.kt
  17. 116 0
      app/src/main/java/io/legado/app/model/recyclerView/MangaVH.kt
  18. 11 0
      app/src/main/java/io/legado/app/model/recyclerView/MangeContent.kt
  19. 8 0
      app/src/main/java/io/legado/app/model/recyclerView/ReaderLoading.kt
  20. 7 3
      app/src/main/java/io/legado/app/ui/about/ReadRecordActivity.kt
  21. 4 2
      app/src/main/java/io/legado/app/ui/association/FileAssociationActivity.kt
  22. 3 2
      app/src/main/java/io/legado/app/ui/association/FileAssociationViewModel.kt
  23. 3 1
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  24. 10 9
      app/src/main/java/io/legado/app/ui/book/import/BaseImportBookActivity.kt
  25. 1 1
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt
  26. 1 1
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt
  27. 9 4
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  28. 240 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaViewModel.kt
  29. 483 0
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  30. 64 0
      app/src/main/java/io/legado/app/ui/book/manga/rv/GestureDetectorWithLongTap.kt
  31. 230 0
      app/src/main/java/io/legado/app/ui/book/manga/rv/MangaAdapter.kt
  32. 85 0
      app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonFrame.kt
  33. 390 0
      app/src/main/java/io/legado/app/ui/book/manga/rv/WebtoonRecyclerView.kt
  34. 236 0
      app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
  35. 3 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/style1/books/BooksFragment.kt
  36. 2 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/style2/BookshelfFragment2.kt
  37. 205 0
      app/src/main/java/io/legado/app/ui/widget/ReaderInfoBarView.kt
  38. 7 3
      app/src/main/java/io/legado/app/ui/widget/recycler/LoadMoreView.kt
  39. 6 0
      app/src/main/java/io/legado/app/utils/ActivityExtensions.kt
  40. 21 6
      app/src/main/java/io/legado/app/utils/ContextExtensions.kt
  41. 16 0
      app/src/main/java/io/legado/app/utils/FragmentExtensions.kt
  42. 28 0
      app/src/main/java/io/legado/app/utils/RecyclerViewExtensions.kt
  43. 93 0
      app/src/main/res/layout/activity_mange.xml
  44. 16 0
      app/src/main/res/layout/book_comic_loading_rv.xml
  45. 64 0
      app/src/main/res/layout/book_comic_rv.xml
  46. 73 0
      app/src/main/res/layout/view_manga_menu.xml
  47. 21 0
      app/src/main/res/menu/book_manga.xml
  48. 3 0
      app/src/main/res/values-es-rES/strings.xml
  49. 3 0
      app/src/main/res/values-ja-rJP/strings.xml
  50. 3 0
      app/src/main/res/values-pt-rBR/strings.xml
  51. 3 0
      app/src/main/res/values-vi/strings.xml
  52. 3 0
      app/src/main/res/values-zh-rHK/strings.xml
  53. 3 0
      app/src/main/res/values-zh-rTW/strings.xml
  54. 3 0
      app/src/main/res/values-zh/strings.xml
  55. 2 0
      app/src/main/res/values/colors.xml
  56. 3 0
      app/src/main/res/values/strings.xml
  57. 12 0
      app/src/main/res/xml/pref_config_other.xml
  58. 3 0
      gradle/libs.versions.toml

+ 3 - 0
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')

+ 16 - 0
app/src/main/AndroidManifest.xml

@@ -24,6 +24,7 @@
     <application
         android:name=".App"
         android:allowBackup="true"
+        android:largeHeap="true"
         android:icon="@mipmap/ic_launcher"
         android:label="${app_name}"
         android:networkSecurityConfig="@xml/network_security_config"
@@ -134,6 +135,21 @@
                 android:name="com.samsung.android.support.REMOTE_ACTION"
                 android:resource="@xml/spen_remote_actions" />
         </activity>
+
+        <!-- 阅读漫画界面 -->
+        <activity
+            android:name=".ui.book.manga.ReadMangaActivity"
+            android:configChanges="locale|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
+            android:exported="true"
+            android:launchMode="singleTask">
+            <intent-filter>
+                <action android:name="com.samsung.android.support.REMOTE_ACTION" />
+            </intent-filter>
+
+            <meta-data
+                android:name="com.samsung.android.support.REMOTE_ACTION"
+                android:resource="@xml/spen_remote_actions" />
+        </activity>
         <!-- 书籍详情页 -->
         <activity
             android:name=".ui.book.info.BookInfoActivity"

+ 1 - 1
app/src/main/assets/18PlusList.txt

@@ -281,4 +281,4 @@ eGlhb3FpYW5nNTIw
 dGlhbm1lbmd3ZW5rdQ==
 YWlmdXNodQ==
 bWlhb2R1NQ==
-bWlmZW5neHM=
+bWlmZW5neHM=

+ 1 - 1
app/src/main/java/io/legado/app/base/adapter/RecyclerAdapter.kt

@@ -475,7 +475,7 @@ abstract class RecyclerAdapter<ITEM, VB : ViewBinding>(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() }
     }
 

+ 2 - 0
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"

+ 8 - 0
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)
 }
 

+ 21 - 1
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)
+            }
+    }
 }

+ 2 - 1
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<GlideUrl?, InputStream?> {
+object OkHttpModeLoaderFactory: ModelLoaderFactory<GlideUrl?, InputStream?> {
 
     override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<GlideUrl?, InputStream?> {
         return OkHttpModelLoader

+ 6 - 3
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<GlideUrl?, InputStream?> {
 
     val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false)
     val sourceOriginOption = Option.memory<String>("sourceOrigin")
+    val mangaOption = Option.memory<Boolean>("manga",false)
 
     override fun buildLoadData(
         model: GlideUrl,
         width: Int,
         height: Int,
-        options: Options
+        options: Options,
     ): ModelLoader.LoadData<InputStream?> {
         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 {

+ 24 - 4
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<InputStream>, 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)
             }

+ 3 - 0
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)?

+ 51 - 0
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<String, OnProgressListener>()
+
+    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]
+    }
+}

+ 54 - 0
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())
+    }
+
+}

+ 12 - 1
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) {

+ 34 - 3
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<Drawable> {
         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<Drawable> {
+        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<Drawable> {
         val loadBlur = ImageLoader.load(context, defaultDrawable)
             .transform(BlurTransformation(25), CenterCrop())

+ 638 - 0
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<Int>()
+    var simulatedChapterSize = 0
+    var mCallback: Callback? = null
+    var mFirstLoading = false
+    var gameOver = false
+    var mTopChapter: BookChapter? = null
+    var preDownloadTask: Job? = null
+    val downloadedChapters = hashSetOf<Int>()
+    val downloadFailChapters = hashMapOf<Int, Int>()
+    private val downloadLoadingChapters = arrayListOf<Int>()
+    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<Any>()
+            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<Any>)
+        fun loadComplete()
+        fun loadFail(msg: String)
+        fun noData()
+        fun adjustmentProgress()
+        fun sureNewProgress(progress: BookProgress)
+        val chapterList: MutableList<Any>
+    }
+}

+ 116 - 0
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<VB : ViewBinding>(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<Drawable> {
+                override fun onLoadFailed(
+                    e: GlideException?,
+                    model: Any?,
+                    target: Target<Drawable>,
+                    isFirstResource: Boolean,
+                ): Boolean {
+                    mFlProgress.isVisible = true
+                    mLoading.isGone = true
+                    mRetry?.isVisible = true
+                    mProgress.isGone = true
+                    itemView.updateLayoutParams<ViewGroup.LayoutParams> {
+                        height = MATCH_PARENT
+                    }
+                    return false
+                }
+
+                override fun onResourceReady(
+                    resource: Drawable,
+                    model: Any,
+                    target: Target<Drawable>?,
+                    dataSource: DataSource,
+                    isFirstResource: Boolean,
+                ): Boolean {
+                    mFlProgress.isGone = true
+                    itemView.updateLayoutParams<ViewGroup.LayoutParams> {
+                        height = WRAP_CONTENT
+                    }
+                    return false
+                }
+            }).into(mImage)
+        } catch (e: Exception) {
+            e.printOnDebug()
+        }
+
+    }
+}

+ 11 - 0
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//当前章节内容总数
+)

+ 8 - 0
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,
+)

+ 7 - 3
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<ActivityReadRecordBinding>() {
                 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<ActivityReadRecordBinding>() {
             holder: ItemViewHolder,
             binding: ItemReadRecordBinding,
             item: ReadRecordShow,
-            payloads: MutableList<Any>
+            payloads: MutableList<Any>,
         ) {
             binding.apply {
                 tvBookName.text = item.bookName
@@ -189,7 +193,7 @@ class ReadRecordActivity : BaseActivity<ActivityReadRecordBinding>() {
                         if (book == null) {
                             SearchActivity.start(this@ReadRecordActivity, item.bookName)
                         } else {
-                            startActivity<ReadBookActivity> {
+                            startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
                                 putExtra("bookUrl", book.bookUrl)
                             }
                         }

+ 4 - 2
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<ReadBookActivity> {
-                putExtra("bookUrl", it)
+            startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(it) {
+                putExtra("bookUrl", it.bookUrl)
             }
             finish()
         }

+ 3 - 2
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<Uri>()
     val onLineImportLive = MutableLiveData<Uri>()
-    val openBookLiveData = MutableLiveData<String>()
+    val openBookLiveData = MutableLiveData<Book>()
     val notSupportedLiveData = MutableLiveData<Pair<Uri, String>>()
 
     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)
     }
 }

+ 3 - 1
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<ReadBookActivity> {
+                startReadOrMangaActivity<ReadBookActivity,ReadMangaActivity>(book) {
                     putExtra("bookUrl", book.bookUrl)
                 }
                 finish()

+ 10 - 9
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<VM : ViewModel> : VMBaseActivity<ActivityI
 
     abstract fun onSearchTextChange(newText: String?)
 
-    protected fun startReadBook(bookUrl: String) {
-        startActivity<ReadBookActivity> {
-            putExtra("bookUrl", bookUrl)
+    protected fun startReadBook(book: Book) {
+        startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
+            putExtra("bookUrl", book.bookUrl)
         }
     }
 
@@ -110,7 +111,7 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
         if (fileNames.size == 1) {
             val name = fileNames[0]
             appDb.bookDao.getBookByFileName(name)?.let {
-                startReadBook(it.bookUrl)
+                startReadBook(it)
             } ?: showImportAlert(fileDoc, name)
         } else {
             showSelectBookReadAlert(fileDoc, fileNames)
@@ -127,7 +128,7 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
             fileNames
         ) { _, name, _ ->
             appDb.bookDao.getBookByFileName(name)?.let {
-                startReadBook(it.bookUrl)
+                startReadBook(it)
             } ?: showImportAlert(fileDoc, name)
         }
     }
@@ -136,12 +137,12 @@ abstract class BaseImportBookActivity<VM : ViewModel> : VMBaseActivity<ActivityI
     private inline fun addArchiveToBookShelf(
         fileDoc: FileDoc,
         fileName: String,
-        onSuccess: (String) -> Unit
+        onSuccess: (Book) -> Unit
     ) {
         LocalBook.importArchiveFile(fileDoc.uri, fileName) {
             it.contains(fileName)
         }.firstOrNull()?.run {
-            onSuccess.invoke(bookUrl)
+            onSuccess.invoke(this)
         }
     }
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookActivity.kt

@@ -304,7 +304,7 @@ class ImportBookActivity : BaseImportBookActivity<ImportBookViewModel>(),
     override fun startRead(fileDoc: FileDoc) {
         if (!ArchiveUtils.isArchive(fileDoc.name)) {
             appDb.bookDao.getBookByFileName(fileDoc.name)?.let {
-                startReadBook(it.bookUrl)
+                startReadBook(it)
             }
         } else {
             onArchiveFileClick(fileDoc)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookActivity.kt

@@ -228,7 +228,7 @@ class RemoteBookActivity : BaseImportBookActivity<RemoteBookViewModel>(),
         val downloadFileName = remoteBook.filename
         if (!ArchiveUtils.isArchive(downloadFileName)) {
             appDb.bookDao.getBookByFileName(downloadFileName)?.let {
-                startReadBook(it.bookUrl)
+                startReadBook(it)
             }
         } else {
             AppConfig.defaultBookTreeUri ?: return

+ 9 - 4
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<String>,
-        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)

+ 240 - 0
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<BookChapter>) {
+        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()
+    }
+}

+ 483 - 0
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<ActivityMangeBinding, MangaViewModel>(),
+    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<Any>(
+            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<MangaViewModel>()
+
+    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<Any>) {
+        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<Any>
+        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<BookChapter>) {
+        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)
+    }
+
+}

+ 64 - 0
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) {
+        }
+    }
+}

+ 230 - 0
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<RecyclerView.ViewHolder>(), PreloadModelProvider<Any> {
+
+    companion object {
+        private const val LOADING_VIEW = 0
+        private const val CONTENT_VIEW = 1
+    }
+
+    private val mDiffCallback: DiffUtil.ItemCallback<Any> = object : DiffUtil.ItemCallback<Any>() {
+        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<Any>, 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<BookComicRvBinding>(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<ViewGroup.LayoutParams> {
+                    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<Any> {
+        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
+    }
+}

+ 85 - 0
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
+        }
+    }
+}

+ 390 - 0
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

+ 236 - 0
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<WebViewActivity> {
+                    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)
+    }
+
+}

+ 3 - 1
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<ReadBookActivity> {
+            else -> startReadOrMangaActivity<ReadBookActivity, ReadMangaActivity>(book) {
                 putExtra("bookUrl", book.bookUrl)
             }
         }

+ 2 - 1
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<AudioPlayActivity> {
                         putExtra("bookUrl", item.bookUrl)
                     }
-                else -> startActivity<ReadBookActivity> {
+                else -> startReadOrMangaActivity<ReadBookActivity,ReadMangaActivity>(item) {
                     putExtra("bookUrl", item.bookUrl)
                 }
             }

+ 205 - 0
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()
+        }
+    }
+}

+ 7 - 3
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

+ 6 - 0
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())
 }

+ 21 - 6
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 <reified A : Activity> Context.startActivity(configIntent: Intent.()
     startActivity(intent)
 }
 
+inline fun <reified A : Activity, reified M : Activity> 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 <reified T : Service> Context.startService(configIntent: Intent.() -> Unit = {}) {
     startService(Intent(this, T::class.java).apply(configIntent))
 }
@@ -65,7 +80,7 @@ inline fun <reified T : Service> Context.stopService() {
 inline fun <reified T : Service> 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 <reified T : Service> 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 <reified T : Activity> 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 <reified T : Activity> Context.activityPendingIntent(
 @SuppressLint("UnspecifiedImmutableFlag")
 inline fun <reified T : BroadcastReceiver> 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<String>? = null
+    defValue: MutableSet<String>? = null,
 ): MutableSet<String>? = defaultSharedPreferences.getStringSet(key, defValue)
 
 fun Context.putPrefStringSet(key: String, value: MutableSet<String>) =
@@ -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) {

+ 16 - 0
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 <reified T : DialogFragment> Fragment.showDialogFragment(
@@ -81,6 +84,19 @@ inline fun <reified T : Activity> Fragment.startActivity(
     startActivity(Intent(requireContext(), T::class.java).apply(configIntent))
 }
 
+inline fun <reified A : Activity, reified M : Activity> 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))

+ 28 - 0
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
+}

+ 93 - 0
app/src/main/res/layout/activity_mange.xml

@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+<io.legado.app.ui.book.manga.rv.WebtoonFrame xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <io.legado.app.ui.book.manga.rv.WebtoonRecyclerView
+        android:id="@+id/mRecyclerMange"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+    <io.legado.app.ui.widget.ReaderInfoBarView
+        android:id="@+id/infobar"
+        android:layout_width="match_parent"
+        android:layout_height="20dp"
+        android:layout_gravity="bottom"
+        android:layout_marginBottom="10dp"
+        android:paddingStart="10dp"
+        android:paddingEnd="10dp"
+        android:visibility="gone"
+        tools:visibility="visible" />
+
+    <io.legado.app.ui.book.read.MangaMenu
+        android:id="@+id/mangaMenu"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:visibility="gone"
+        tools:visibility="visible" />
+
+
+    <FrameLayout
+        android:id="@+id/fl_loading"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="@color/white">
+
+        <LinearLayout
+            android:id="@+id/ll_loading"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:gravity="center"
+            android:orientation="vertical">
+
+            <ProgressBar
+                style="@style/Widget.Material3.CircularProgressIndicator.Small"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:indeterminate="true"
+                app:indicatorColor="@color/white"
+                app:indicatorSize="36dp"
+                app:trackCornerRadius="99dp"
+                app:trackThickness="3dp" />
+
+            <TextView
+                android:id="@+id/tv_loading_message"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="10dp"
+                android:text="正在加载中..."
+                android:textColor="@color/black" />
+        </LinearLayout>
+
+        <LinearLayout
+            android:id="@+id/ll_retry"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:visibility="gone"
+            android:gravity="center"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/tv_msg"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:textColor="@color/black" />
+
+            <Button
+                android:id="@+id/retry"
+                style="@style/Widget.Material3.Button.TonalButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center"
+                android:text="重新加载"
+                android:textSize="18sp"
+                app:cornerRadius="10dp"
+                tools:visibility="visible" />
+        </LinearLayout>
+
+    </FrameLayout>
+
+</io.legado.app.ui.book.manga.rv.WebtoonFrame>

+ 16 - 0
app/src/main/res/layout/book_comic_loading_rv.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="96dp"
+    android:background="@color/book_ant_10">
+
+    <TextView
+        android:id="@+id/text"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:textColor="@android:color/white"
+        android:textStyle="bold"
+        tools:text="上一章节:第一话" />
+</FrameLayout>

+ 64 - 0
app/src/main/res/layout/book_comic_rv.xml

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/rootView"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/book_ant_10"
+    android:contentDescription="@null">
+
+    <com.github.panpf.zoomimage.GlideZoomImageView
+        android:id="@+id/image"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:adjustViewBounds="true"
+        app:contentScale="fillWidth" />
+
+    <FrameLayout
+        android:id="@+id/fl_progress"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:background="@color/book_ant_10">
+
+        <ProgressBar
+            android:id="@+id/loading"
+            style="@style/Widget.Material3.CircularProgressIndicator.Small"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:indeterminate="true"
+            android:indeterminateTint="@color/white"
+            android:progressTint="@color/white"
+            app:indicatorColor="@color/white"
+            app:indicatorSize="36dp"
+            app:trackCornerRadius="99dp"
+            app:trackThickness="3dp" />
+
+
+        <Button
+            android:id="@+id/retry"
+            style="@style/Widget.Material3.Button.TonalButton"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:text="重新加载"
+            android:textColor="@color/white"
+            android:textSize="18sp"
+            android:visibility="gone"
+            app:backgroundTint="@color/white"
+            app:cornerRadius="10dp"
+            tools:visibility="visible" />
+
+        <TextView
+            android:id="@+id/progress"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_gravity="center"
+            android:text="0%"
+            android:textColor="@color/white"
+            android:visibility="gone"
+            tools:visibility="visible" />
+    </FrameLayout>
+
+</FrameLayout>

+ 73 - 0
app/src/main/res/layout/view_manga_menu.xml

@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/vw_menu_root"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <View
+        android:id="@+id/vw_menu_bg"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:contentDescription="@string/content"
+        tools:layout_editor_absoluteX="0dp"
+        tools:layout_editor_absoluteY="0dp" />
+
+    <io.legado.app.ui.widget.TitleBar
+        android:id="@+id/title_bar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:theme="?attr/actionBarStyle"
+        app:layout_constraintTop_toTopOf="parent">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            android:id="@+id/title_bar_addition"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <TextView
+                android:id="@+id/tv_chapter_name"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:paddingLeft="10dp"
+                android:paddingRight="10dp"
+                android:singleLine="true"
+                android:visibility="gone"
+                app:layout_constraintLeft_toLeftOf="parent"
+                app:layout_constraintRight_toLeftOf="@+id/tv_source_action"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <TextView
+                android:id="@+id/tv_chapter_url"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:paddingLeft="10dp"
+                android:paddingRight="10dp"
+                android:singleLine="true"
+                android:visibility="gone"
+                app:layout_constraintLeft_toLeftOf="parent"
+                app:layout_constraintRight_toLeftOf="@+id/tv_source_action"
+                app:layout_constraintTop_toBottomOf="@+id/tv_chapter_name" />
+
+            <io.legado.app.ui.widget.text.AccentBgTextView
+                android:id="@+id/tv_source_action"
+                android:layout_width="wrap_content"
+                android:layout_height="0dp"
+                android:layout_margin="1dp"
+                android:gravity="center"
+                android:maxWidth="120dp"
+                android:paddingLeft="6dp"
+                android:paddingRight="6dp"
+                android:text="@string/book_source"
+                app:layout_constraintBottom_toBottomOf="@+id/tv_chapter_url"
+                app:layout_constraintRight_toRightOf="parent"
+                app:layout_constraintTop_toTopOf="parent"
+                app:radius="2dp" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+    </io.legado.app.ui.widget.TitleBar>
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 21 - 0
app/src/main/res/menu/book_manga.xml

@@ -0,0 +1,21 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:context=".ui.main.MainActivity"
+    tools:ignore="AlwaysShowAction">
+
+    <group android:id="@+id/menu_group_on_line">
+        <item
+            android:id="@+id/menu_change_source"
+            android:icon="@drawable/ic_exchange"
+            android:title="@string/change_origin"
+            app:showAsAction="always" />
+
+        <item
+            android:id="@+id/menu_catalog"
+            android:icon="@drawable/ic_toc"
+            android:title="@string/chapter_list"
+            app:showAsAction="always" />
+
+    </group>
+</menu>

+ 3 - 0
app/src/main/res/values-es-rES/strings.xml

@@ -1180,6 +1180,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">Descargar el siguiente capítulo</string>
     <string name="menu_download_all">Descargar todos los capítulos</string>
 </resources>

+ 3 - 0
app/src/main/res/values-ja-rJP/strings.xml

@@ -1183,6 +1183,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">下载之后章节</string>
     <string name="menu_download_all">下载全部章节</string>
 </resources>

+ 3 - 0
app/src/main/res/values-pt-rBR/strings.xml

@@ -1183,6 +1183,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">Faça o download do próximo capítulo</string>
     <string name="menu_download_all">Download de todos os capítulos</string>
 </resources>

+ 3 - 0
app/src/main/res/values-vi/strings.xml

@@ -1179,6 +1179,9 @@ Còn </string>
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">Tải xuống chương tiếp theo</string>
     <string name="menu_download_all">Tải xuống tất cả các chương</string>
 </resources>

+ 3 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1180,6 +1180,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">下載之後章節</string>
     <string name="menu_download_all">下載全部章節</string>
 </resources>

+ 3 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1182,6 +1182,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">下載之後章節</string>
     <string name="menu_download_all">下載全部章節</string>
 </resources>

+ 3 - 0
app/src/main/res/values-zh/strings.xml

@@ -1182,6 +1182,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">下载之后章节</string>
     <string name="menu_download_all">下载全部章节</string>
 </resources>

+ 2 - 0
app/src/main/res/values/colors.xml

@@ -111,4 +111,6 @@
     <color name="ate_switch_thumb_disabled_dark">#FF424242</color>
     <!---->
     <color name="ate_navigation_drawer_selected_dark">#202020</color>
+
+    <color name="book_ant_10">#141414</color>
 </resources>

+ 3 - 0
app/src/main/res/values/strings.xml

@@ -1183,6 +1183,9 @@
     <string name="read_aloud_by_media_button_summary">通过耳机按键来启动朗读</string>
     <string name="show_same_source">显示重复书源</string>
     <string name="theme_config">主题配置</string>
+    <string name="show_mange_ui">漫画浏览</string>
+    <string name="disable_manga_scaling">禁用漫画缩放</string>
+    <string name="book_reader_info_bar">页数. %1$d/%2$d  章节. %3$d/%4$d -> %5$s%%</string>
     <string name="menu_download_after">Download the chapter after</string>
     <string name="menu_download_all">Download all chapter</string>
 </resources>

+ 12 - 0
app/src/main/res/xml/pref_config_other.xml

@@ -42,6 +42,18 @@
             android:title="@string/show_rss"
             app:iconSpaceReserved="false" />
 
+        <io.legado.app.lib.prefs.SwitchPreference
+            android:defaultValue="true"
+            android:key="showMangaUi"
+            android:title="@string/show_mange_ui"
+            app:iconSpaceReserved="false" />
+
+        <io.legado.app.lib.prefs.SwitchPreference
+            android:defaultValue="true"
+            android:key="disableMangaScaling"
+            android:title="@string/disable_manga_scaling"
+            app:iconSpaceReserved="false" />
+
         <io.legado.app.lib.prefs.NameListPreference
             android:defaultValue="bookshelf"
             android:key="defaultHomePage"

+ 3 - 0
gradle/libs.versions.toml

@@ -115,6 +115,8 @@ room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
 room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
 
 glide-glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" }
+glide-okhttp= { module = "com.github.bumptech.glide:okhttp3-integration", version.ref ="glide" }
+glide-recyclerview={module="com.github.bumptech.glide:recyclerview-integration",version.ref="glide"}
 glide-compiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glide" }
 glide-compose = { module = "com.github.bumptech.glide:compose", version = "1.0.0-beta01" }
 glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
@@ -159,6 +161,7 @@ mozilla-rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
 
 renderscript-intrinsics-replacement-toolkit = { module = "com.github.android:renderscript-intrinsics-replacement-toolkit", version = "b6363490c3" }
 zxing-lite = { module = "com.github.jenly1314:zxing-lite", version.ref = "zxingLite" }
+zoom-imageview="io.github.panpf.zoomimage:zoomimage-view-glide:1.1.1"
 
 [bundles]
 coroutines = ["kotlinx-coroutines-core", "kotlinx-coroutines-android"]