HapeLee пре 1 месец
родитељ
комит
e67ed773d8

+ 4 - 0
.gitignore

@@ -22,3 +22,7 @@ node_modules/
 /.debug-runs/
 /.debug-runs/
 __pycache__/
 __pycache__/
 *.py[cod]
 *.py[cod]
+# JVM / Gradle daemon 崩溃产物(多 JVM 构建时可能落在仓库根目录)
+hs_err_pid*.log
+replay_pid*.log
+*.hprof

+ 28 - 1
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -9,6 +9,7 @@ import coil3.fetch.Fetcher
 import coil3.fetch.SourceFetchResult
 import coil3.fetch.SourceFetchResult
 import coil3.request.Options
 import coil3.request.Options
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
+import io.legado.app.help.glide.progress.ProgressUrlTag
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.isWifiConnect
 import io.legado.app.utils.isWifiConnect
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CancellationException
@@ -94,6 +95,12 @@ class CoverFetcher(
         val bookUrl = options.extras[CoverExtras.BookUrl]
         val bookUrl = options.extras[CoverExtras.BookUrl]
 
 
         val requestHeaders = options.extras[CoverExtras.Headers]
         val requestHeaders = options.extras[CoverExtras.Headers]
+        // 进度回报键:用书源规则改写前的原始地址,与阅读页持有的 imageUrl 一致
+        val progressUrl = options.extras[CoverExtras.OriginalUrl] ?: url
+        // 无需书源二次解密时可以把响应流直接交给解码器边下边解,
+        // 省掉“整张图先落一份 ByteArray”这一步(高画质大图时这就是转圈等很久的主因)
+        val canStream = isManga && ImageUtils.skipDecode(source, !isManga)
+        var streamedSource: ImageSource? = null
 
 
         // ===== 第二级:OkHttp HTTP 缓存(FORCE_CACHE 只读缓存,miss 返回 504,不碰网络)=====
         // ===== 第二级:OkHttp HTTP 缓存(FORCE_CACHE 只读缓存,miss 返回 504,不碰网络)=====
         // 注意:WiFi 限制与失败冷却不能挡在本地缓存读取之前,
         // 注意:WiFi 限制与失败冷却不能挡在本地缓存读取之前,
@@ -105,6 +112,7 @@ class CoverFetcher(
                 val cacheRequest = Request.Builder()
                 val cacheRequest = Request.Builder()
                     .url(url)
                     .url(url)
                     .tag(io.legado.app.data.entities.BaseSource::class.java, source)
                     .tag(io.legado.app.data.entities.BaseSource::class.java, source)
+                    .tag(ProgressUrlTag::class.java, ProgressUrlTag(progressUrl))
                     .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                     .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                     .cacheControl(CacheControl.FORCE_CACHE)
                     .cacheControl(CacheControl.FORCE_CACHE)
                     .build()
                     .build()
@@ -138,6 +146,7 @@ class CoverFetcher(
                     val networkRequest = Request.Builder()
                     val networkRequest = Request.Builder()
                         .url(url)
                         .url(url)
                         .tag(io.legado.app.data.entities.BaseSource::class.java, source)
                         .tag(io.legado.app.data.entities.BaseSource::class.java, source)
+                        .tag(ProgressUrlTag::class.java, ProgressUrlTag(progressUrl))
                         .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                         .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                         .tag(COVER_REQUEST_TAG)
                         .tag(COVER_REQUEST_TAG)
                         .cacheControl(
                         .cacheControl(
@@ -152,7 +161,15 @@ class CoverFetcher(
                         body.close()
                         body.close()
                         throw IOException("HTTP ${networkResponse.code}")
                         throw IOException("HTTP ${networkResponse.code}")
                     }
                     }
-                    body.use { it.bytes() }
+                    if (canStream) {
+                        streamedSource = ImageSource(
+                            source = body.source(),
+                            fileSystem = options.fileSystem,
+                        )
+                        null
+                    } else {
+                        body.use { it.bytes() }
+                    }
                 }
                 }
             } catch (e: CancellationException) {
             } catch (e: CancellationException) {
                 throw e
                 throw e
@@ -185,6 +202,16 @@ class CoverFetcher(
             }
             }
         }
         }
 
 
+        // 流式分支:字节直接由解码器消费,这里只负责收尾,不再落 ByteArray。
+        streamedSource?.let { stream ->
+            clearFailure(url)
+            return SourceFetchResult(
+                source = stream,
+                mimeType = null,
+                dataSource = DataSource.NETWORK,
+            )
+        }
+
         // 到这里必定已拿到字节(本地缓存/OkHttp 缓存/网络三选一,否则已抛出)。
         // 到这里必定已拿到字节(本地缓存/OkHttp 缓存/网络三选一,否则已抛出)。
         // rawBytes 在 lambda 内赋值,Kotlin 无法智能转换为非空,这里显式收敛。
         // rawBytes 在 lambda 内赋值,Kotlin 无法智能转换为非空,这里显式收敛。
         val fetchedBytes = rawBytes ?: throw IOException("封面数据为空: $url")
         val fetchedBytes = rawBytes ?: throw IOException("封面数据为空: $url")

+ 24 - 0
app/src/main/java/io/legado/app/help/glide/progress/DownloadProgress.kt

@@ -0,0 +1,24 @@
+package io.legado.app.help.glide.progress
+
+/**
+ * 图片下载进度事件。
+ *
+ * [url] 是「原始图片地址」(书源规则解析前的地址),与阅读页持有的 page.imageUrl 一致,
+ * 由 [ProgressUrlTag] 显式带到 OkHttp 请求上,不依赖最终 URL 的字符串匹配。
+ */
+data class DownloadProgress(
+    val url: String,
+    /** 0..100;仅当 [totalBytes] > 0 时才会有事件产生。 */
+    val percentage: Int,
+    val bytesRead: Long,
+    val totalBytes: Long,
+    val isComplete: Boolean,
+)
+
+/**
+ * OkHttp 请求 tag:告诉进度拦截器这个请求的进度应该回报给哪个 URL。
+ *
+ * 漫画正文的 URL 会被书源规则(AnalyzeUrl)改写成带 token 的最终地址,
+ * 直接用 `request.url` 做 key 会与阅读页持有的原始地址对不上。
+ */
+data class ProgressUrlTag(val url: String)

+ 58 - 15
app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt

@@ -1,36 +1,71 @@
 package io.legado.app.help.glide.progress
 package io.legado.app.help.glide.progress
 
 
+import io.legado.app.help.glide.progress.ProgressManager.addListener
+import io.legado.app.help.glide.progress.ProgressManager.progress
+import io.legado.app.help.glide.progress.ProgressManager.removeListener
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
 
 
 /**
 /**
  * 进度监听器管理类
  * 进度监听器管理类
  * 加入图片加载进度监听,加入Https支持
  * 加入图片加载进度监听,加入Https支持
+ *
+ * 两条消费路径并存:
+ * 1. [progress] 全局事件流,供阅读页 ViewModel 订阅(无需注册/注销,生命周期安全);
+ * 2. [addListener] / [removeListener] 单个 URL 的回调,兼容旧的 View 侧用法。
+ *
+ * 只有当至少一方在监听时才向主线程投递事件,避免预取 N 张图时刷爆主线程消息队列。
  */
  */
 object ProgressManager {
 object ProgressManager {
-    private val listenersMap = ConcurrentHashMap<String, OnProgressListener>()
+    private val listenersMap = ConcurrentHashMap<String, CopyOnWriteArrayList<OnProgressListener>>()
+
+    private val _progress = MutableSharedFlow<DownloadProgress>(
+        replay = 0,
+        extraBufferCapacity = 64,
+        onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
+    )
+    val progress: SharedFlow<DownloadProgress> = _progress.asSharedFlow()
 
 
     val LISTENER = object : ProgressResponseBody.InternalProgressListener {
     val LISTENER = object : ProgressResponseBody.InternalProgressListener {
         override fun onProgress(url: String, bytesRead: Long, totalBytes: Long) {
         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
-                }
-                it.invoke(isComplete, percentage, bytesRead, totalBytes)
-                if (isComplete) {
-                    removeListener(url)
-                }
+            if (!hasConsumer()) return
+            // Content-Length 缺失(分块传输/服务端不返回)时不计算百分比,
+            // 也不判完成,否则旧实现会把 percentage 算成负数并立刻注销监听。
+            if (totalBytes <= 0L) return
+            val key = getUrlNoOption(url)
+            val listeners = listenersMap[key]
+            val percentage = (bytesRead * 100f / totalBytes).toInt().coerceIn(0, 100)
+            val isComplete = bytesRead >= totalBytes
+            listeners?.forEach { it.invoke(isComplete, percentage, bytesRead, totalBytes) }
+            _progress.tryEmit(
+                DownloadProgress(
+                    url = key,
+                    percentage = percentage,
+                    bytesRead = bytesRead,
+                    totalBytes = totalBytes,
+                    isComplete = isComplete,
+                )
+            )
+            if (isComplete) {
+                removeListener(url)
             }
             }
         }
         }
     }
     }
 
 
+    private fun hasConsumer(): Boolean =
+        _progress.subscriptionCount.value > 0 || listenersMap.isNotEmpty()
+
     fun addListener(url: String, listener: OnProgressListener) {
     fun addListener(url: String, listener: OnProgressListener) {
         if (url.isNotEmpty()) {
         if (url.isNotEmpty()) {
             val url = getUrlNoOption(url)
             val url = getUrlNoOption(url)
-            listenersMap[url] = listener
+            listenersMap.getOrPut(url) { CopyOnWriteArrayList() }.apply {
+                remove(listener)
+                add(listener)
+            }
             listener.invoke(false, 1, 0, 0)
             listener.invoke(false, 1, 0, 0)
         }
         }
     }
     }
@@ -42,11 +77,19 @@ object ProgressManager {
         }
         }
     }
     }
 
 
+    fun removeListener(url: String, listener: OnProgressListener) {
+        if (url.isEmpty()) return
+        val key = getUrlNoOption(url)
+        val listeners = listenersMap[key] ?: return
+        listeners.remove(listener)
+        if (listeners.isEmpty()) listenersMap.remove(key, listeners)
+    }
+
     fun getProgressListener(url: String): OnProgressListener? {
     fun getProgressListener(url: String): OnProgressListener? {
         return if (url.isEmpty() || listenersMap.isEmpty()) {
         return if (url.isEmpty() || listenersMap.isEmpty()) {
             null
             null
         } else {
         } else {
-            listenersMap[url]
+            listenersMap[getUrlNoOption(url)]?.firstOrNull()
         }
         }
     }
     }
 
 
@@ -59,4 +102,4 @@ object ProgressManager {
         }
         }
     }
     }
 
 
-}
+}

+ 20 - 4
app/src/main/java/io/legado/app/help/glide/progress/ProgressResponseBody.kt

@@ -4,9 +4,12 @@ import android.os.Handler
 import android.os.Looper
 import android.os.Looper
 import okhttp3.MediaType
 import okhttp3.MediaType
 import okhttp3.ResponseBody
 import okhttp3.ResponseBody
-import okio.*
+import okio.Buffer
+import okio.BufferedSource
+import okio.ForwardingSource
+import okio.Source
+import okio.buffer
 import java.io.IOException
 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() {
 class ProgressResponseBody internal constructor(private val url: String, private val internalProgressListener: InternalProgressListener?, private val responseBody: ResponseBody) : ResponseBody() {
     private var bufferedSource: BufferedSource? = null
     private var bufferedSource: BufferedSource? = null
@@ -29,14 +32,26 @@ class ProgressResponseBody internal constructor(private val url: String, private
         return object : ForwardingSource(source) {
         return object : ForwardingSource(source) {
             var totalBytesRead: Long = 0
             var totalBytesRead: Long = 0
             var lastTotalBytesRead: Long = 0
             var lastTotalBytesRead: Long = 0
+            var lastEmitAt: Long = 0
 
 
             @Throws(IOException::class)
             @Throws(IOException::class)
             override fun read(sink: Buffer, byteCount: Long): Long {
             override fun read(sink: Buffer, byteCount: Long): Long {
                 val bytesRead = super.read(sink, byteCount)
                 val bytesRead = super.read(sink, byteCount)
                 totalBytesRead += if (bytesRead == -1L) 0 else bytesRead
                 totalBytesRead += if (bytesRead == -1L) 0 else bytesRead
                 if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) {
                 if (internalProgressListener != null && lastTotalBytesRead != totalBytesRead) {
-                    lastTotalBytesRead = totalBytesRead
-                    mainThreadHandler.post { internalProgressListener.onProgress(url, totalBytesRead, contentLength()) }
+                    // 每 8KB 一次主线程 post,在“预取若干页 + 高画质大图”时会把主线程消息队列
+                    // 打满,直接表现为阅读页滑动卡顿。这里按时间节流,收尾时补发一次终值。
+                    val now = android.os.SystemClock.uptimeMillis()
+                    val isFinished = bytesRead == -1L
+                    if (isFinished || now - lastEmitAt >= EMIT_INTERVAL_MS) {
+                        lastEmitAt = now
+                        lastTotalBytesRead = totalBytesRead
+                        mainThreadHandler.post {
+                            internalProgressListener.onProgress(
+                                url, totalBytesRead, contentLength()
+                            )
+                        }
+                    }
                 }
                 }
                 return bytesRead
                 return bytesRead
             }
             }
@@ -49,6 +64,7 @@ class ProgressResponseBody internal constructor(private val url: String, private
 
 
     companion object {
     companion object {
         private val mainThreadHandler = Handler(Looper.getMainLooper())
         private val mainThreadHandler = Handler(Looper.getMainLooper())
+        private const val EMIT_INTERVAL_MS = 100L
     }
     }
 
 
 }
 }

+ 6 - 3
app/src/main/java/io/legado/app/help/http/HttpHelper.kt

@@ -1,12 +1,13 @@
 package io.legado.app.help.http
 package io.legado.app.help.http
 
 
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst
+import io.legado.app.data.entities.BaseSource
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
+import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.glide.progress.ProgressManager.LISTENER
 import io.legado.app.help.glide.progress.ProgressManager.LISTENER
 import io.legado.app.help.glide.progress.ProgressResponseBody
 import io.legado.app.help.glide.progress.ProgressResponseBody
+import io.legado.app.help.glide.progress.ProgressUrlTag
 import io.legado.app.help.http.CookieManager.cookieJarHeader
 import io.legado.app.help.http.CookieManager.cookieJarHeader
-import io.legado.app.data.entities.BaseSource
-import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.NetworkUtils
 import okhttp3.Cache
 import okhttp3.Cache
 import okhttp3.ConnectionSpec
 import okhttp3.ConnectionSpec
@@ -152,7 +153,9 @@ val okHttpClientManga by lazy {
         interceptors.add(1) { chain ->
         interceptors.add(1) { chain ->
             val request = chain.request()
             val request = chain.request()
             val response = chain.proceed(request)
             val response = chain.proceed(request)
-            val url = request.url.toString()
+            // 漫画正文地址会被书源规则改写,进度必须回报给“原始地址”才能与阅读页的
+            // page.imageUrl 对上;普通封面请求没有 tag,退回最终 URL。
+            val url = request.tag(ProgressUrlTag::class.java)?.url ?: request.url.toString()
             response.newBuilder()
             response.newBuilder()
                 .body(ProgressResponseBody(url, LISTENER, response.body))
                 .body(ProgressResponseBody(url, LISTENER, response.body))
                 .build()
                 .build()

+ 7 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -95,7 +95,13 @@ sealed interface MangaReaderItemUi {
 @Stable
 @Stable
 sealed interface MangaPageLoadState {
 sealed interface MangaPageLoadState {
     data object Queued : MangaPageLoadState
     data object Queued : MangaPageLoadState
-    data object Loading : MangaPageLoadState
+
+    /**
+     * @param progress 下载百分比 0..100;服务端未返回 Content-Length 时为 null,
+     * 此时 UI 退回不确定进度(只转圈,不显示数字)。
+     */
+    data class Loading(val progress: Int? = null) : MangaPageLoadState
+
     data object Ready : MangaPageLoadState
     data object Ready : MangaPageLoadState
     data class Failed(val message: String?) : MangaPageLoadState
     data class Failed(val message: String?) : MangaPageLoadState
 }
 }

+ 90 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -30,6 +30,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.CircularProgressIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
@@ -84,6 +85,8 @@ import coil3.request.ImageRequest
 import coil3.request.allowHardware
 import coil3.request.allowHardware
 import coil3.request.crossfade
 import coil3.request.crossfade
 import coil3.request.transformations
 import coil3.request.transformations
+import coil3.size.Dimension
+import coil3.size.Size
 import coil3.toBitmap
 import coil3.toBitmap
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
@@ -99,6 +102,7 @@ import io.legado.app.ui.book.manga.config.MangaZoomStartPosition
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
@@ -195,6 +199,13 @@ fun MangaReaderScreen(
 
 
     val context = LocalContext.current
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val lifecycleOwner = LocalLifecycleOwner.current
+    val pageDecodeSize = mangaPageDecodeSize(
+        paged = state.settings.scrollMode != MangaScrollMode.WEBTOON &&
+                state.settings.scrollMode != MangaScrollMode.WEBTOON_WITH_GAP,
+        sidePaddingPercent = state.settings.sidePaddingPercent,
+        pageScaleType = state.settings.pageScaleType,
+        widePageMode = state.settings.widePageMode,
+    )
     val pendingMessage = state.pendingMessages.firstOrNull()
     val pendingMessage = state.pendingMessages.firstOrNull()
     LaunchedEffect(pendingMessage?.id, context, lifecycleOwner) {
     LaunchedEffect(pendingMessage?.id, context, lifecycleOwner) {
         val message = pendingMessage ?: return@LaunchedEffect
         val message = pendingMessage ?: return@LaunchedEffect
@@ -216,6 +227,7 @@ fun MangaReaderScreen(
         state.settings.sourceOrigin,
         state.settings.sourceOrigin,
         state.settings.enableEInk,
         state.settings.enableEInk,
         state.settings.enableGray,
         state.settings.enableGray,
+        pageDecodeSize,
     ) {
     ) {
         val ahead = state.settings.preDownloadCount.coerceIn(0, 10)
         val ahead = state.settings.preDownloadCount.coerceIn(0, 10)
         val current = state.currentItemIndex
         val current = state.currentItemIndex
@@ -233,6 +245,7 @@ fun MangaReaderScreen(
                     page.imageRequest(
                     page.imageRequest(
                         settings = state.settings,
                         settings = state.settings,
                         context = context,
                         context = context,
+                        decodeSize = pageDecodeSize,
                         onAspectRatio = { aspectRatios[page.key] = it },
                         onAspectRatio = { aspectRatios[page.key] = it },
                         onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
                         onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
                         onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
                         onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
@@ -1225,6 +1238,12 @@ private fun MangaPageImage(
     val automaticBackgrounds = LocalMangaBackgroundColors.current
     val automaticBackgrounds = LocalMangaBackgroundColors.current
     val context = LocalContext.current
     val context = LocalContext.current
     val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
     val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
+    val decodeSize = mangaPageDecodeSize(
+        paged = paged,
+        sidePaddingPercent = settings.sidePaddingPercent,
+        pageScaleType = settings.pageScaleType,
+        widePageMode = settings.widePageMode,
+    )
     val webtoonSizeModifier = if (paged) Modifier else {
     val webtoonSizeModifier = if (paged) Modifier else {
         aspectRatios[page.key]?.takeIf { it > 0f }?.let { Modifier.aspectRatio(it) }
         aspectRatios[page.key]?.takeIf { it > 0f }?.let { Modifier.aspectRatio(it) }
             ?: Modifier.height(fallbackHeight)
             ?: Modifier.height(fallbackHeight)
@@ -1237,6 +1256,7 @@ private fun MangaPageImage(
         settings.enableGray,
         settings.enableGray,
         settings.disableCrossFade,
         settings.disableCrossFade,
         page.retryRevision,
         page.retryRevision,
+        decodeSize,
     ) {
     ) {
         listOf(
         listOf(
             page.key,
             page.key,
@@ -1246,12 +1266,14 @@ private fun MangaPageImage(
             settings.enableGray,
             settings.enableGray,
             settings.disableCrossFade,
             settings.disableCrossFade,
             page.retryRevision,
             page.retryRevision,
+            decodeSize,
         )
         )
     }
     }
     val request = remember(imagePipelineKey) {
     val request = remember(imagePipelineKey) {
         page.imageRequest(
         page.imageRequest(
             settings = settings,
             settings = settings,
             context = context,
             context = context,
+            decodeSize = decodeSize,
             onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
             onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
             onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
             onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
             onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
             onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
@@ -1434,18 +1456,77 @@ private fun MangaImageLoadOverlay(
             }
             }
         }
         }
     } else {
     } else {
+        // 加载中不再整页压黑罩(#2082),因此百分比必须自带底色,
+        // 否则在纯白/纯黑的漫画页上都读不出来。
+        val progress = (loadState as? MangaPageLoadState.Loading)?.progress
         Box(
         Box(
             modifier = Modifier.fillMaxSize(),
             modifier = Modifier.fillMaxSize(),
             contentAlignment = Alignment.Center,
             contentAlignment = Alignment.Center,
         ) {
         ) {
-            androidx.compose.material3.CircularProgressIndicator()
+            Column(
+                modifier = Modifier
+                    .background(
+                        color = Color.Black.copy(alpha = 0.45f),
+                        shape = RoundedCornerShape(16.dp),
+                    )
+                    .padding(horizontal = 16.dp, vertical = 12.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                AppCircularProgressIndicator(progress = progress?.let { it / 100f })
+                if (progress != null) {
+                    Text(
+                        text = "$progress%",
+                        color = Color.White,
+                        style = MaterialTheme.typography.labelLarge,
+                    )
+                }
+            }
         }
         }
     }
     }
 }
 }
 
 
+/**
+ * 漫画正文的目标解码尺寸。
+ *
+ * 只按屏幕宽度推导,不读布局约束:条漫页的高度会在拿到宽高比后从「整屏高度」跳到
+ * 「图片真实高度」,用约束推导会让同一张图在不同时机解出不同分辨率。
+ */
+@Composable
+private fun mangaPageDecodeSize(
+    paged: Boolean,
+    sidePaddingPercent: Int,
+    pageScaleType: Int,
+    widePageMode: Int,
+): Size {
+    // 「原始尺寸」模式按位图固有尺寸绘制(ContentScale.None),降采样会直接把页面画小,
+    // 所以这里必须保持不缩放,沿用改动前的语义。
+    if (paged && pageScaleType == MangaPageScaleType.ORIGINAL) return Size.ORIGINAL
+    val density = LocalDensity.current
+    val configuration = LocalConfiguration.current
+    val screenWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
+    // 「旋转适配」把跨页图旋转 90°,图片的长边落在屏幕高度上(rotateWidePage 会交换约束)。
+    // 目标宽度必须换成屏幕高度,否则解码结果只有屏幕宽、显示时被放大两倍以上而发糊。
+    val contentWidthPx = if (paged && widePageMode == MangaWidePageMode.ROTATE_TO_FIT) {
+        maxOf(screenWidthPx, with(density) { configuration.screenHeightDp.dp.roundToPx() })
+    } else {
+        screenWidthPx
+    }
+    val fraction = if (paged) {
+        1f
+    } else {
+        1f - sidePaddingPercent.coerceIn(0, 45) * 2f / 100f
+    }
+    return Size(
+        width = Dimension((contentWidthPx * fraction).roundToInt().coerceAtLeast(1)),
+        height = Dimension.Undefined,
+    )
+}
+
 private fun MangaReaderItemUi.Page.imageRequest(
 private fun MangaReaderItemUi.Page.imageRequest(
     settings: MangaReaderSettings,
     settings: MangaReaderSettings,
     context: android.content.Context,
     context: android.content.Context,
+    decodeSize: Size,
     onAspectRatio: (Float) -> Unit = {},
     onAspectRatio: (Float) -> Unit = {},
     onStart: () -> Unit = {},
     onStart: () -> Unit = {},
     onSuccess: () -> Unit = {},
     onSuccess: () -> Unit = {},
@@ -1456,6 +1537,14 @@ private fun MangaReaderItemUi.Page.imageRequest(
     return ImageRequest.Builder(context)
     return ImageRequest.Builder(context)
         .data(imageUrl)
         .data(imageUrl)
         .allowHardware(true)
         .allowHardware(true)
+        // 解码尺寸必须显式钉住,不能交给 AsyncImage 的 ConstraintsSizeResolver:
+        // 它只在请求启动时取一次当时的布局约束,而条漫页的约束会在拿到宽高比后突变,
+        // 于是同一张高画质图每次进入可能解出不同分辨率;又因为内存缓存键相同,
+        // 预取那轮按 Size.ORIGINAL 解出的全图与展示那轮按视口降采样解出的图会互相覆盖,
+        // 命中哪一份取决于谁先完成 —— 这才是“概率性发糊”的来源。
+        // 预取与展示共用这里钉死的尺寸后,缓存里只会存在同一种分辨率。
+        // 同时它等价于旧版 Glide 的 override(widthPixels, SIZE_ORIGINAL)。
+        .size(decodeSize)
         // The preload request has no view-size resolver while the displayed request does. A
         // The preload request has no view-size resolver while the displayed request does. A
         // shared key lets the displayed request reuse it immediately, then crossfade only if a
         // shared key lets the displayed request reuse it immediately, then crossfade only if a
         // better-sized decode is needed.
         // better-sized decode is needed.

+ 60 - 4
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -20,6 +20,7 @@ import io.legado.app.domain.model.manga.MangaSessionState
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverFetcher
+import io.legado.app.help.glide.progress.ProgressManager
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
@@ -38,6 +39,9 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.sync.withLock
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
+/** 会把下载百分比写进 UiState 的页窗口:当前页前后各若干项。 */
+private const val PAGE_PROGRESS_WINDOW = 3
+
 class MangaReaderViewModel(
 class MangaReaderViewModel(
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
@@ -99,6 +103,13 @@ class MangaReaderViewModel(
                 _uiState.update { it.copy(confirmAddToShelf = settings.showAddToShelfAlert) }
                 _uiState.update { it.copy(confirmAddToShelf = settings.showAddToShelfAlert) }
             }
             }
         }
         }
+        viewModelScope.launch {
+            // 下载进度由 OkHttp 层上报(okHttpClientManga),按“原始图片地址”投递,
+            // 这里只做 URL -> 页面状态的映射,不参与图片请求本身。
+            ProgressManager.progress.collect { event ->
+                applyPageProgress(event.url, event.percentage)
+            }
+        }
     }
     }
 
 
     fun onIntent(intent: MangaReaderIntent) {
     fun onIntent(intent: MangaReaderIntent) {
@@ -263,10 +274,7 @@ class MangaReaderViewModel(
             is MangaReaderIntent.RetryChapter -> executeSession(
             is MangaReaderIntent.RetryChapter -> executeSession(
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
             )
             )
-            is MangaReaderIntent.PageLoadStarted -> updatePageLoadState(
-                intent.key,
-                MangaPageLoadState.Loading,
-            )
+            is MangaReaderIntent.PageLoadStarted -> markPageLoading(intent.key)
 
 
             is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
             is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
                 intent.key,
                 intent.key,
@@ -1220,6 +1228,54 @@ class MangaReaderViewModel(
         }
         }
     }
     }
 
 
+    /**
+     * 置为 Loading,但保留已有百分比:预取请求与展示请求的 onStart 会先后到达,
+     * 直接塞 [MangaPageLoadState.Loading] 会把已经走到的进度清回“不确定”。
+     */
+    private fun markPageLoading(key: String) {
+        _uiState.update { state ->
+            val index = state.pages.indexOfFirst { it.key == key }
+            val page =
+                state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
+            if (page.loadState == MangaPageLoadState.Ready) return@update state
+            val progress = (page.loadState as? MangaPageLoadState.Loading)?.progress
+            val next = MangaPageLoadState.Loading(progress)
+            if (page.loadState == next) return@update state
+            state.copy(
+                pages = state.pages.mapIndexed { itemIndex, item ->
+                    if (itemIndex == index) page.copy(loadState = next) else item
+                }.toImmutableList(),
+            )
+        }
+    }
+
+    /**
+     * 预取页也在并发下载,进度事件最高可达每秒上百条。只有当前页附近的百分比才会被看到,
+     * 因此窗口外的条目直接跳过,避免为了一个看不见的数字重建整份 UiState(进而重组阅读页)。
+     */
+    private fun applyPageProgress(imageUrl: String, percentage: Int) {
+        _uiState.update { state ->
+            val from = state.currentItemIndex - PAGE_PROGRESS_WINDOW
+            val to = state.currentItemIndex + PAGE_PROGRESS_WINDOW
+            var changed = false
+            val pages = state.pages.mapIndexed { itemIndex, item ->
+                if (itemIndex < from || itemIndex > to) {
+                    item
+                } else if (item is MangaReaderItemUi.Page &&
+                    item.imageUrl == imageUrl &&
+                    item.loadState is MangaPageLoadState.Loading &&
+                    item.loadState.progress != percentage
+                ) {
+                    changed = true
+                    item.copy(loadState = MangaPageLoadState.Loading(percentage))
+                } else {
+                    item
+                }
+            }
+            if (changed) state.copy(pages = pages.toImmutableList()) else state
+        }
+    }
+
     private fun updatePageLoadState(key: String, loadState: MangaPageLoadState) {
     private fun updatePageLoadState(key: String, loadState: MangaPageLoadState) {
         _uiState.update { state ->
         _uiState.update { state ->
             val index = state.pages.indexOfFirst { it.key == key }
             val index = state.pages.indexOfFirst { it.key == key }

+ 14 - 3
gradle.properties

@@ -8,11 +8,21 @@
 # The setting is particularly useful for tweaking memory settings.
 # The setting is particularly useful for tweaking memory settings.
 # Default value: -Xmx512m -Xms256m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError
 # Default value: -Xmx512m -Xms256m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError
 # Bugs: This will override important JVM argument defaults. Include all important JVM arguments if you wan to change arguments. See https://github.com/gradle/gradle/issues/19750
 # Bugs: This will override important JVM argument defaults. Include all important JVM arguments if you wan to change arguments. See https://github.com/gradle/gradle/issues/19750
-org.gradle.jvmargs=-Xmx12g -Xms1g -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseG1GC
+# 内存预算(32G 机器实测):Gradle daemon 6g + Kotlin daemon 4g + lint/编译 worker ≈ 8g,
+# 峰值 ~18g,为 IDE、浏览器和系统留出余量。
+# 之前是 -Xmx12g,叠加 kotlin.daemon 的 -Xms3g 与 16 并发 worker 后会同时向系统申请
+# 近 20G 提交内存,AGP lint 阶段直接出现 “Native memory allocation (mmap) failed for G1
+# virtual space”,Gradle daemon 被原生 OOM 干掉。这里把单个 JVM 的上限收回来,
+# 并配合 org.gradle.workers.max 限制并发进程数。
+org.gradle.jvmargs=-Xmx6g -Xms1g -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseG1GC
 # When configured, Gradle will run in incubating parallel mode.
 # When configured, Gradle will run in incubating parallel mode.
 # This option should only be used with decoupled projects. More details, visit
 # This option should only be used with decoupled projects. More details, visit
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
 org.gradle.parallel=true
 org.gradle.parallel=true
+# 并发 worker 进程上限。默认取 CPU 核数(本机 16),AGP lint / Kotlin 编译会各自 fork JVM,
+# 核数越多同时存活的 JVM 越多,内存被放大得越厉害;6 是本机实测能稳定跑完
+# `lintAppDebug` 与完整测试集的取值。
+org.gradle.workers.max=6
 # AndroidX package structure to make it clearer which packages are bundled with the
 # AndroidX package structure to make it clearer which packages are bundled with the
 # Android operating system, and which are packaged with your app's APK
 # Android operating system, and which are packaged with your app's APK
 # https://developer.android.com/topic/libraries/support-library/androidx-rn
 # https://developer.android.com/topic/libraries/support-library/androidx-rn
@@ -50,7 +60,8 @@ org.gradle.java.installations.auto-detect=true
 org.gradle.java.installations.auto-download=false
 org.gradle.java.installations.auto-download=false
 # 构建缓存:任务输入不变时复用输出,加速 clean build / 切换分支 / CI
 # 构建缓存:任务输入不变时复用输出,加速 clean build / 切换分支 / CI
 org.gradle.caching=true
 org.gradle.caching=true
-# Kotlin 守护进程内存:legado Kotlin 代码量大,默认偏小会导致 daemon 重启与 GC 停顿
-kotlin.daemon.jvmargs=-Xmx6g -Xms3g -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
+# Kotlin 守护进程内存:legado Kotlin 代码量大,默认偏小会导致 daemon 重启与 GC 停顿。
+# 4g 上限足够;-Xms 从 3g 降到 512m,避免 daemon 一启动就向系统真实提交 3G 内存。
+kotlin.daemon.jvmargs=-Xmx4g -Xms512m -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
 # Gradle 9.4+:显式开启 Tooling API 并行(IDE sync),避免依赖 org.gradle.parallel 的弃用隐式推导
 # Gradle 9.4+:显式开启 Tooling API 并行(IDE sync),避免依赖 org.gradle.parallel 的弃用隐式推导
 org.gradle.tooling.parallel=true
 org.gradle.tooling.parallel=true