HapeLee 2 месяцев назад
Родитель
Сommit
c4cd6afdff
32 измененных файлов с 1371 добавлено и 182 удалено
  1. 1 1
      app/src/main/java/io/legado/app/constant/AppPattern.kt
  2. 4 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 8 0
      app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt
  4. 18 5
      app/src/main/java/io/legado/app/data/repository/manga/DefaultMangaReaderSession.kt
  5. 128 0
      app/src/main/java/io/legado/app/data/repository/manga/LocalMangaLoader.kt
  6. 85 0
      app/src/main/java/io/legado/app/data/repository/manga/MangaChapterPageLoader.kt
  7. 71 2
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt
  8. 40 38
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt
  9. 6 1
      app/src/main/java/io/legado/app/domain/gateway/MangaReaderDataGateway.kt
  10. 1 0
      app/src/main/java/io/legado/app/domain/model/manga/MangaReaderSessionModels.kt
  11. 4 0
      app/src/main/java/io/legado/app/domain/model/settings/MangaSettings.kt
  12. 43 11
      app/src/main/java/io/legado/app/model/localBook/LocalBook.kt
  13. 11 0
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  14. 17 1
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  15. 44 5
      app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt
  16. 46 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt
  17. 12 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt
  18. 25 9
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt
  19. 17 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt
  20. 154 73
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  21. 152 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt
  22. 256 16
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt
  23. 48 4
      app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt
  24. 13 1
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  25. 1 0
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaPageConfig.kt
  26. 7 8
      app/src/main/java/io/legado/app/utils/ContextExtensions.kt
  27. 17 0
      app/src/main/res/values-zh-rCN/strings.xml
  28. 17 0
      app/src/main/res/values/strings.xml
  29. 9 1
      app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt
  30. 16 3
      app/src/test/java/io/legado/app/data/repository/manga/DefaultMangaReaderSessionTest.kt
  31. 43 0
      app/src/test/java/io/legado/app/data/repository/manga/LocalMangaLoaderTest.kt
  32. 57 0
      app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

+ 1 - 1
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -47,7 +47,7 @@ object AppPattern {
     //本地书籍支持类型
     val bookFileRegex = Regex(".*\\.(txt|epub|umd|pdf|mobi|azw3|azw)", RegexOption.IGNORE_CASE)
     //压缩文件支持类型
-    val archiveFileRegex = Regex(".*\\.(zip|rar|7z)$", RegexOption.IGNORE_CASE)
+    val archiveFileRegex = Regex(".*\\.(zip|cbz|rar|7z)$", RegexOption.IGNORE_CASE)
 
     /**
      * 所有标点

+ 4 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -122,6 +122,7 @@ object PreferKey {
     const val imageRetainNum = "imageRetainNum"
     const val preDownloadNum = "preDownloadNum"
     const val mangaPreDownloadNum = "mangaPreDownloadNum"
+    const val mangaChapterPrefetchCount = "mangaChapterPrefetchCount"
     const val mangaAutoPageSpeed = "mangaAutoPageSpeed"
     const val mangaFooterConfig = "mangaFooterConfig"
     const val disableClickScroll = "disableClickScroll"
@@ -295,6 +296,9 @@ object PreferKey {
     const val mangaZoomStartPosition = "mangaZoomStartPosition"
     const val mangaWidePageMode = "mangaWidePageMode"
     const val mangaDoublePageMode = "mangaDoublePageMode"
+    const val mangaDoublePageCoverSingle = "mangaDoublePageCoverSingle"
+    const val mangaDoublePageInvert = "mangaDoublePageInvert"
+    const val mangaDoublePageShift = "mangaDoublePageShift"
     const val mangaMenuTopBarLiquidGlass = "mangaMenuTopBarLiquidGlass"
     const val mangaMenuBottomBarLiquidGlass = "mangaMenuBottomBarLiquidGlass"
     const val mangaMenuBottomBarFloating = "mangaMenuBottomBarFloating"

+ 8 - 0
app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt

@@ -37,6 +37,7 @@ internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.disableMangaCrossFade to disableMangaCrossFade,
     PreferKey.mangaScrollMode to scrollMode,
     PreferKey.mangaPreDownloadNum to preDownloadNum,
+    PreferKey.mangaChapterPrefetchCount to chapterPrefetchCount,
     PreferKey.mangaAutoPageSpeed to autoPageSpeed,
     PreferKey.mangaFooterConfig to footerConfig,
     PreferKey.disableClickScroll to disableClickScroll,
@@ -47,6 +48,9 @@ internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.mangaZoomStartPosition to zoomStartPosition,
     PreferKey.mangaWidePageMode to widePageMode,
     PreferKey.mangaDoublePageMode to doublePageMode,
+    PreferKey.mangaDoublePageCoverSingle to doublePageCoverSingle,
+    PreferKey.mangaDoublePageInvert to doublePageInvert,
+    PreferKey.mangaDoublePageShift to doublePageShift,
     PreferKey.mangaColorFilter to colorFilter,
     PreferKey.hideMangaTitle to hideTitle,
     PreferKey.enableMangaEInk to enableEInk,
@@ -81,6 +85,7 @@ internal fun Preferences.toMangaSettings(): MangaSettings = MangaSettings(
     disableMangaCrossFade = compatDsBoolean(PreferKey.disableMangaCrossFade) ?: false,
     scrollMode = compatDsInt(PreferKey.mangaScrollMode) ?: 4,
     preDownloadNum = compatDsInt(PreferKey.mangaPreDownloadNum) ?: 10,
+    chapterPrefetchCount = compatDsInt(PreferKey.mangaChapterPrefetchCount) ?: 1,
     autoPageSpeed = compatDsInt(PreferKey.mangaAutoPageSpeed) ?: 3,
     footerConfig = compatDsString(PreferKey.mangaFooterConfig).orEmpty(),
     disableClickScroll = compatDsBoolean(PreferKey.disableClickScroll) ?: false,
@@ -91,6 +96,9 @@ internal fun Preferences.toMangaSettings(): MangaSettings = MangaSettings(
     zoomStartPosition = compatDsInt(PreferKey.mangaZoomStartPosition) ?: 0,
     widePageMode = compatDsInt(PreferKey.mangaWidePageMode) ?: 0,
     doublePageMode = compatDsInt(PreferKey.mangaDoublePageMode) ?: 0,
+    doublePageCoverSingle = compatDsBoolean(PreferKey.mangaDoublePageCoverSingle) ?: true,
+    doublePageInvert = compatDsBoolean(PreferKey.mangaDoublePageInvert) ?: false,
+    doublePageShift = compatDsBoolean(PreferKey.mangaDoublePageShift) ?: false,
     colorFilter = compatDsString(PreferKey.mangaColorFilter).orEmpty(),
     hideTitle = compatDsBoolean(PreferKey.hideMangaTitle) ?: false,
     enableEInk = compatDsBoolean(PreferKey.enableMangaEInk) ?: false,

+ 18 - 5
app/src/main/java/io/legado/app/data/repository/manga/DefaultMangaReaderSession.kt

@@ -4,7 +4,6 @@ import io.legado.app.R
 import io.legado.app.domain.gateway.MangaReaderDataGateway
 import io.legado.app.domain.gateway.MangaReaderSession
 import io.legado.app.domain.model.manga.MangaChapterState
-import io.legado.app.domain.model.manga.MangaLoadToken
 import io.legado.app.domain.model.manga.MangaSessionCommand
 import io.legado.app.domain.model.manga.MangaSessionEvent
 import io.legado.app.domain.model.manga.MangaSessionId
@@ -202,6 +201,7 @@ class DefaultMangaReaderSession(
     }
 
     private fun loadWindow(chapterIndex: Int) {
+        retainChapterResources(chapterIndex)
         loadChapter(chapterIndex)
         prefetch(chapterIndex)
     }
@@ -229,17 +229,18 @@ class DefaultMangaReaderSession(
         )
         if (movingForward) loadChapter(command.chapterIndex + 1)
         else loadChapter(command.chapterIndex - 1)
+        retainChapterResources(command.chapterIndex)
         prefetch(command.chapterIndex)
     }
 
     private fun prefetch(chapterIndex: Int) {
         val state = _state.value
         val book = state.book ?: return
-        val indexes = (chapterIndex + 2..chapterIndex + 1 + prefetchCount)
+        val indexes = (chapterIndex + 1..chapterIndex + prefetchCount)
             .filter { it in 0 until state.chapterCount }
-        indexes.forEach { index ->
-            val token = state.tokenFor(index) ?: return@forEach
-            loadScope.launch {
+        loadScope.launch {
+            indexes.forEach { index ->
+                val token = state.tokenFor(index) ?: return@forEach
                 if (_state.value.accepts(token)) {
                     runCatching { dataGateway.prefetchChapter(book.bookUrl, index) }
                 }
@@ -371,6 +372,17 @@ class DefaultMangaReaderSession(
         loadScope = CoroutineScope(loadJob + ioDispatcher)
     }
 
+    private fun retainChapterResources(chapterIndex: Int) {
+        val state = _state.value
+        val bookUrl = state.book?.bookUrl ?: return
+        val retained = (chapterIndex - 1..chapterIndex + 1)
+            .filter { it in 0 until state.chapterCount }
+            .toSet()
+        loadScope.launch {
+            dataGateway.retainChapterResources(bookUrl, retained)
+        }
+    }
+
     private suspend fun syncProgress() {
         val book = _state.value.book ?: return
         withContext(ioDispatcher) { dataGateway.syncProgress(book.bookUrl) }?.let {
@@ -398,6 +410,7 @@ class DefaultMangaReaderSession(
         _state.value.book?.let {
             withContext(ioDispatcher) { dataGateway.pause(it.bookUrl, it.inBookshelf) }
         }
+        withContext(ioDispatcher) { dataGateway.releaseAllChapterResources() }
         commands.close()
         sessionScope.cancel()
     }

+ 128 - 0
app/src/main/java/io/legado/app/data/repository/manga/LocalMangaLoader.kt

@@ -0,0 +1,128 @@
+package io.legado.app.data.repository.manga
+
+import androidx.core.net.toUri
+import androidx.documentfile.provider.DocumentFile
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.model.manga.MangaChapterContent
+import io.legado.app.domain.model.manga.MangaPageContent
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.utils.AlphanumComparator
+import io.legado.app.utils.ArchiveUtils
+import io.legado.app.utils.MD5Utils
+import splitties.init.appCtx
+import java.io.File
+
+/** Dedicated local comic metadata loader for image directories and comic archives. */
+internal class LocalMangaLoader(private val cacheRoot: File) : AutoCloseable {
+    private val extractedRoots = linkedSetOf<File>()
+    private val extractedBooks = mutableMapOf<String, File>()
+
+    fun supports(book: Book): Boolean = book.originName.endsWith(".cbz", true) ||
+            book.originName.endsWith(".zip", true) || localDirectory(book) != null
+
+    fun chapters(book: Book): List<BookChapter> =
+        imageGroups(book).keys.mapIndexed { index, title ->
+            BookChapter(
+                url = "local-manga://chapter/$index",
+                title = title,
+                bookUrl = book.bookUrl,
+                index = index,
+            )
+        }
+
+    fun load(book: Book, chapter: BookChapter): MangaChapterContent {
+        val entry = imageGroups(book).entries.elementAtOrNull(chapter.index)
+            ?: throw NoStackTraceException("Local comic chapter is missing")
+        val urls = entry.value.map(ImageEntry::url)
+        return MangaChapterContent(
+            chapter.index,
+            chapter.title,
+            chapter.url,
+            urls.mapIndexed { index, url -> MangaPageContent(url, index, urls.size) },
+            false,
+        )
+    }
+
+    private fun imageGroups(book: Book): Map<String, List<ImageEntry>> {
+        val images = localDirectory(book)?.let(::directoryImages) ?: archiveImages(book)
+        if (images.isEmpty()) throw NoStackTraceException("Local comic contains no images")
+        val sorted = images.sortedWith(compareBy(AlphanumComparator) { it.path })
+        val grouped = sorted.groupBy { image ->
+            image.path.substringBeforeLast('/', "").takeIf { it.isNotBlank() } ?: book.name
+        }
+        return if (grouped.size == sorted.size) mapOf(book.name to sorted) else grouped
+    }
+
+    private fun archiveImages(book: Book): List<ImageEntry> {
+        val root = extractedBooks.getOrPut(book.bookUrl) { extract(book) }
+        return root.walkTopDown()
+            .filter { it.isFile && it.extension.lowercase() in IMAGE_EXTENSIONS }
+            .map { ImageEntry(it.relativeTo(root).invariantSeparatorsPath, it.toURI().toString()) }
+            .toList()
+    }
+
+    private fun extract(book: Book): File {
+        val base = File(cacheRoot, MD5Utils.md5Encode16(book.bookUrl)).apply { mkdirs() }
+        extractedRoots += base
+        val files = ArchiveUtils.deCompress(book.bookUrl, base.path) { path ->
+            path.substringAfterLast('.').lowercase() in IMAGE_EXTENSIONS
+        }
+        return files.map(File::getParentFile).filterNotNull().reduceOrNull(::commonParent) ?: base
+    }
+
+    private fun localDirectory(book: Book): Any? {
+        return if (book.bookUrl.startsWith("content://")) {
+            val uri = book.bookUrl.toUri()
+            DocumentFile.fromTreeUri(appCtx, uri)?.takeIf { it.isDirectory }
+        } else {
+            val path = if (book.bookUrl.startsWith("file:")) {
+                java.net.URI(book.bookUrl).path
+            } else book.bookUrl
+            File(path).takeIf { it.isDirectory }
+        }
+    }
+
+    private fun directoryImages(root: Any): List<ImageEntry> = when (root) {
+        is File -> root.walkTopDown()
+            .filter { it.isFile && it.extension.lowercase() in IMAGE_EXTENSIONS }
+            .map { ImageEntry(it.relativeTo(root).invariantSeparatorsPath, it.toURI().toString()) }
+            .toList()
+
+        is DocumentFile -> documentImages(root, "")
+        else -> emptyList()
+    }
+
+    private fun documentImages(directory: DocumentFile, prefix: String): List<ImageEntry> =
+        directory.listFiles().flatMap { child ->
+            val path =
+                if (prefix.isEmpty()) child.name.orEmpty() else "$prefix/${child.name.orEmpty()}"
+            when {
+                child.isDirectory -> documentImages(child, path)
+                child.isFile && child.name.orEmpty().substringAfterLast('.', "")
+                    .lowercase() in IMAGE_EXTENSIONS ->
+                    listOf(ImageEntry(path, child.uri.toString()))
+
+                else -> emptyList()
+            }
+        }
+
+    private fun commonParent(first: File, second: File): File {
+        var candidate: File? = first
+        while (candidate != null && !second.toPath().startsWith(candidate.toPath())) candidate =
+            candidate.parentFile
+        return candidate ?: first
+    }
+
+    override fun close() {
+        extractedRoots.forEach { it.deleteRecursively() }
+        extractedRoots.clear()
+        extractedBooks.clear()
+    }
+
+    private companion object {
+        val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "webp", "gif", "avif", "bmp")
+    }
+
+    private data class ImageEntry(val path: String, val url: String)
+}

+ 85 - 0
app/src/main/java/io/legado/app/data/repository/manga/MangaChapterPageLoader.kt

@@ -0,0 +1,85 @@
+package io.legado.app.data.repository.manga
+
+import android.app.Application
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.model.manga.MangaChapterContent
+import io.legado.app.domain.model.manga.MangaPageContent
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.book.BookHelp
+import io.legado.app.model.webBook.WebBook
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.toList
+
+/** Selects where chapter page metadata comes from without leaking that choice into presentation. */
+internal class MangaChapterPageLoader(
+    private val application: Application,
+    private val database: AppDatabase,
+) : AutoCloseable {
+
+    private val retainedChapters =
+        java.util.concurrent.ConcurrentHashMap<ChapterKey, LoadedChapter>()
+
+    suspend fun load(
+        book: Book,
+        chapter: BookChapter,
+        retain: Boolean = true,
+    ): MangaChapterContent {
+        val key = ChapterKey(book.bookUrl, chapter.index)
+        retainedChapters[key]?.let { return it.content }
+        val content = BookHelp.getContent(book, chapter)
+            ?: RemoteChapterContentLoader(application, database).load(book, chapter)
+        val imageUrls = BookHelp.flowImages(chapter, content)
+            .distinctUntilChanged()
+            .toList()
+        if (imageUrls.isEmpty() && !chapter.isVolume) {
+            throw NoStackTraceException(application.getString(io.legado.app.R.string.content_empty))
+        }
+        return MangaChapterContent(
+            chapterIndex = chapter.index,
+            chapterTitle = chapter.title,
+            chapterUrl = chapter.url,
+            pages = imageUrls.mapIndexed { index, url ->
+                MangaPageContent(url, index, imageUrls.size)
+            },
+            isVolume = chapter.isVolume,
+        ).also { if (retain) retainedChapters[key] = LoadedChapter(it) }
+    }
+
+    fun retain(bookUrl: String, chapterIndexes: Set<Int>) {
+        retainedChapters.entries.removeAll { (key, loaded) ->
+            val release = key.bookUrl != bookUrl || key.chapterIndex !in chapterIndexes
+            if (release) loaded.close()
+            release
+        }
+    }
+
+    override fun close() {
+        retainedChapters.values.forEach(LoadedChapter::close)
+        retainedChapters.clear()
+    }
+
+    private data class ChapterKey(val bookUrl: String, val chapterIndex: Int)
+
+    /** Boundary for future archive/directory handles; current URL-backed chapters own no open stream. */
+    private class LoadedChapter(val content: MangaChapterContent) : AutoCloseable {
+        override fun close() = Unit
+    }
+}
+
+private class RemoteChapterContentLoader(
+    private val application: Application,
+    private val database: AppDatabase,
+) {
+    suspend fun load(book: Book, chapter: BookChapter): String {
+        val source = database.bookSourceDao.getBookSource(book.origin)
+            ?: throw NoStackTraceException(application.getString(io.legado.app.R.string.manga_reader_details_failed))
+        val nextUrl = database.bookChapterDao.getChapter(book.bookUrl, chapter.index + 1)?.url
+        return WebBook.getContentAwait(source, book, chapter, nextUrl).also { content ->
+            if (content.isEmpty() && !chapter.isVolume) {
+                throw NoStackTraceException(application.getString(io.legado.app.R.string.content_empty))
+            }
+        }
+    }
+}

+ 71 - 2
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt

@@ -13,17 +13,18 @@ import io.legado.app.data.AppDatabase
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book.ReadConfig
 import io.legado.app.data.entities.BookChapter
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
-import io.legado.app.help.coil.CoverExtras
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.removeType
+import io.legado.app.help.coil.CoverExtras
 import io.legado.app.help.source.getSourceType
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
+import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.isAbsUrl
@@ -31,6 +32,8 @@ import io.legado.app.utils.isTrue
 import io.legado.app.utils.postEvent
 import kotlinx.coroutines.currentCoroutineContext
 import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileOutputStream
 
 sealed interface MangaReaderActionPaymentResult {
     data class OpenUrl(
@@ -133,6 +136,72 @@ class MangaReaderActionRepository(
         return ImageSaveUtils.saveImageToGallery(application, bytes, folderName = folderName)
     }
 
+    suspend fun prepareImageFile(
+        urls: List<String>,
+        bookUrl: String,
+        sourceOrigin: String?,
+    ): File {
+        require(urls.isNotEmpty())
+        val bitmaps = urls.map { loadBitmap(it, bookUrl, sourceOrigin) }
+        val bitmap = if (bitmaps.size == 1) bitmaps.single() else combineHorizontally(bitmaps)
+        val directory = File(application.cacheDir, "manga-actions").apply { mkdirs() }
+        return File(directory, "manga-${System.currentTimeMillis()}.jpg").also { file ->
+            FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.JPEG, 95, it) }
+        }
+    }
+
+    suspend fun saveImages(urls: List<String>, bookUrl: String, sourceOrigin: String?): Boolean {
+        val file = prepareImageFile(urls, bookUrl, sourceOrigin)
+        return ImageSaveUtils.saveImageToGallery(
+            application,
+            file.readBytes(),
+            folderName = "Legado"
+        )
+    }
+
+    suspend fun setBookCover(bookUrl: String, imageUrl: String) {
+        val book = database.bookDao.getBook(bookUrl) ?: return
+        val sourceFile = prepareImageFile(listOf(imageUrl), book.bookUrl, book.origin)
+        val coverFile = File(LocalBook.getCoverPath(book)).apply { parentFile?.mkdirs() }
+        sourceFile.copyTo(coverFile, overwrite = true)
+        book.customCoverUrl = coverFile.toURI().toString()
+        database.bookDao.update(book)
+    }
+
+    private suspend fun loadBitmap(url: String, bookUrl: String, sourceOrigin: String?): Bitmap {
+        val result = imageLoader.execute(
+            ImageRequest.Builder(application)
+                .data(url)
+                .allowHardware(false)
+                .apply {
+                    extras[CoverExtras.Manga] = true
+                    extras[CoverExtras.MangaBookUrl] = bookUrl
+                    extras[CoverExtras.SourceOrigin] = sourceOrigin
+                }
+                .build()
+        )
+        return requireNotNull(result.image).toBitmap()
+    }
+
+    private fun combineHorizontally(bitmaps: List<Bitmap>): Bitmap {
+        val height = bitmaps.maxOf(Bitmap::getHeight)
+        val widths = bitmaps.map { it.width * height / it.height }
+        return Bitmap.createBitmap(widths.sum(), height, Bitmap.Config.ARGB_8888).also { output ->
+            val canvas = android.graphics.Canvas(output)
+            var left = 0f
+            bitmaps.forEachIndexed { index, bitmap ->
+                val width = widths[index]
+                canvas.drawBitmap(
+                    bitmap,
+                    null,
+                    android.graphics.RectF(left, 0f, left + width, height.toFloat()),
+                    null
+                )
+                left += width
+            }
+        }
+    }
+
     suspend fun payCurrentChapter(
         bookUrl: String,
         chapterIndex: Int,

+ 40 - 38
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt

@@ -1,22 +1,22 @@
 package io.legado.app.data.repository.manga
 
 import android.app.Application
-import io.legado.app.R
 import io.legado.app.BuildConfig
+import io.legado.app.R
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.readRecord.ReadRecordSession
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.domain.gateway.BackupSettingsGateway
+import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.MangaReaderDataGateway
-import io.legado.app.domain.usecase.GetReadingProgressUseCase
-import io.legado.app.domain.usecase.UploadReadingProgressUseCase
-import io.legado.app.domain.model.manga.MangaBookState
 import io.legado.app.domain.model.manga.MangaBookPresentation
+import io.legado.app.domain.model.manga.MangaBookState
 import io.legado.app.domain.model.manga.MangaChapterContent
-import io.legado.app.domain.model.manga.MangaPageContent
 import io.legado.app.domain.model.manga.MangaProgressState
 import io.legado.app.domain.model.manga.OpenedMangaBook
+import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import io.legado.app.domain.usecase.UploadReadingProgressUseCase
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isLocal
@@ -24,12 +24,14 @@ import io.legado.app.help.book.isLocalModified
 import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.source.getSourceType
+import io.legado.app.help.storage.Backup
+import io.legado.app.model.cache.CacheDownloadRequest
+import io.legado.app.model.cache.CacheDownloadSource
+import io.legado.app.model.cache.ChapterSelection
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.webBook.WebBook
-import io.legado.app.help.storage.Backup
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.toList
 
 class MangaReaderDataRepository(
     private val application: Application,
@@ -38,9 +40,13 @@ class MangaReaderDataRepository(
     private val uploadReadingProgressUseCase: UploadReadingProgressUseCase,
     private val backupSettingsGateway: BackupSettingsGateway,
     private val readRecordRepository: ReadRecordRepository,
+    private val bookCacheDownloadGateway: BookCacheDownloadGateway,
 ) : MangaReaderDataGateway {
 
     private var readingStartedAt: Long? = null
+    private val chapterPageLoader = MangaChapterPageLoader(application, database)
+    private val localMangaLoader =
+        LocalMangaLoader(java.io.File(application.cacheDir, "local-manga"))
 
     override fun observeBookPresentation(bookUrl: String) =
         database.bookDao.flowGetBook(bookUrl)
@@ -79,7 +85,12 @@ class MangaReaderDataRepository(
         var chapterCount = database.bookChapterDao.getChapterCount(book.bookUrl)
         if (chapterCount == 0 || book.isLocalModified()) {
             if (book.isLocal) {
-                val chapters = LocalBook.getChapterList(book)
+                val chapters = if (localMangaLoader.supports(book)) {
+                    localMangaLoader.chapters(book).also {
+                        book.totalChapterNum = it.size
+                        book.latestChapterTitle = it.lastOrNull()?.title.orEmpty()
+                    }
+                } else LocalBook.getChapterList(book)
                 database.bookChapterDao.delByBook(book.bookUrl)
                 database.bookChapterDao.insert(*chapters.toTypedArray())
                 database.bookDao.update(book)
@@ -120,6 +131,7 @@ class MangaReaderDataRepository(
                 inBookshelf = inBookshelf,
                 scrollMode = book.readConfig?.mangaScrollMode,
                 sidePaddingDp = book.readConfig?.webtoonSidePaddingDp,
+                isLocal = book.isLocal,
                 chapterTitles = database.bookChapterDao.getChapterList(book.bookUrl).map { it.title },
             ),
             chapterIndex = safeChapter,
@@ -134,40 +146,30 @@ class MangaReaderDataRepository(
             ?: throw NoStackTraceException(application.getString(R.string.no_book))
         val chapter = database.bookChapterDao.getChapter(bookUrl, chapterIndex)
             ?: throw NoStackTraceException(application.getString(R.string.error_load_toc))
-        val cached = BookHelp.getContent(book, chapter)
-        val content = cached ?: run {
-            val source = database.bookSourceDao.getBookSource(book.origin)
-                ?: throw NoStackTraceException("加载内容失败 没有书源")
-            val nextUrl = database.bookChapterDao.getChapter(bookUrl, chapterIndex + 1)?.url
-            WebBook.getContentAwait(source, book, chapter, nextUrl)
-        }
-        if (content.isEmpty() && !chapter.isVolume) {
-            throw NoStackTraceException("正文内容为空")
-        }
-        val imageUrls = BookHelp.flowImages(chapter, content)
-            .distinctUntilChanged()
-            .toList()
-        if (imageUrls.isEmpty() && !chapter.isVolume) {
-            throw NoStackTraceException("正文没有图片")
-        }
-        return MangaChapterContent(
-            chapterIndex = chapterIndex,
-            chapterTitle = chapter.title,
-            chapterUrl = chapter.url,
-            pages = imageUrls.mapIndexed { index, url ->
-                MangaPageContent(url, index, imageUrls.size)
-            },
-            isVolume = chapter.isVolume,
-        )
+        return if (book.isLocal && localMangaLoader.supports(book)) {
+            localMangaLoader.load(book, chapter)
+        } else chapterPageLoader.load(book, chapter)
     }
 
     override suspend fun prefetchChapter(bookUrl: String, chapterIndex: Int) {
-        loadChapter(bookUrl, chapterIndex)
         val book = database.bookDao.getBook(bookUrl) ?: return
-        val chapter = database.bookChapterDao.getChapter(bookUrl, chapterIndex) ?: return
-        val source = database.bookSourceDao.getBookSource(book.origin) ?: return
-        val content = BookHelp.getContent(book, chapter) ?: return
-        BookHelp.saveImages(source, book, chapter, content)
+        if (book.isLocal) return
+        bookCacheDownloadGateway.start(
+            CacheDownloadRequest(
+                bookUrl = bookUrl,
+                selection = ChapterSelection.Single(chapterIndex),
+                source = CacheDownloadSource.ReadPreload,
+            )
+        )
+    }
+
+    override suspend fun retainChapterResources(bookUrl: String, chapterIndexes: Set<Int>) {
+        chapterPageLoader.retain(bookUrl, chapterIndexes)
+    }
+
+    override suspend fun releaseAllChapterResources() {
+        chapterPageLoader.close()
+        localMangaLoader.close()
     }
 
     override suspend fun persistProgress(bookUrl: String, chapterIndex: Int, pageIndex: Int) {

+ 6 - 1
app/src/main/java/io/legado/app/domain/gateway/MangaReaderDataGateway.kt

@@ -1,7 +1,7 @@
 package io.legado.app.domain.gateway
 
-import io.legado.app.domain.model.manga.MangaChapterContent
 import io.legado.app.domain.model.manga.MangaBookPresentation
+import io.legado.app.domain.model.manga.MangaChapterContent
 import io.legado.app.domain.model.manga.MangaProgressState
 import io.legado.app.domain.model.manga.OpenedMangaBook
 import kotlinx.coroutines.flow.Flow
@@ -19,6 +19,11 @@ interface MangaReaderDataGateway {
 
     suspend fun prefetchChapter(bookUrl: String, chapterIndex: Int)
 
+    /** Releases chapter-scoped resources outside the active reader window. */
+    suspend fun retainChapterResources(bookUrl: String, chapterIndexes: Set<Int>) = Unit
+
+    suspend fun releaseAllChapterResources() = Unit
+
     suspend fun persistProgress(
         bookUrl: String,
         chapterIndex: Int,

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/manga/MangaReaderSessionModels.kt

@@ -21,6 +21,7 @@ data class MangaBookState(
     val inBookshelf: Boolean,
     val scrollMode: Int?,
     val sidePaddingDp: Int?,
+    val isLocal: Boolean,
     /** TOC titles are retained so a chapter transition stays informative before pages load. */
     val chapterTitles: List<String>,
 )

+ 4 - 0
app/src/main/java/io/legado/app/domain/model/settings/MangaSettings.kt

@@ -7,6 +7,7 @@ data class MangaSettings(
     val disableMangaCrossFade: Boolean = false,
     val scrollMode: Int = 4,
     val preDownloadNum: Int = 10,
+    val chapterPrefetchCount: Int = 1,
     val autoPageSpeed: Int = 3,
     val footerConfig: String = "",
     val disableClickScroll: Boolean = false,
@@ -17,6 +18,9 @@ data class MangaSettings(
     val zoomStartPosition: Int = 0,
     val widePageMode: Int = 0,
     val doublePageMode: Int = 0,
+    val doublePageCoverSingle: Boolean = true,
+    val doublePageInvert: Boolean = false,
+    val doublePageShift: Boolean = false,
     val colorFilter: String = "",
     val hideTitle: Boolean = false,
     val enableEInk: Boolean = false,

+ 43 - 11
app/src/main/java/io/legado/app/model/localBook/LocalBook.kt

@@ -241,6 +241,8 @@ object LocalBook {
      * 导入本地文件
      */
     fun importFile(uri: Uri): Book {
+        val input = FileDoc.fromUri(uri, false)
+        if (input.isDir) return importMangaDirectory(input)
         val bookUrl: String
         //updateTime变量不要修改,否则会导致读取不到缓存
         val (fileName, _, _, updateTime, _) = FileDoc.fromUri(uri, false).apply {
@@ -276,6 +278,32 @@ object LocalBook {
         return book
     }
 
+    fun importMangaDirectory(directory: FileDoc): Book {
+        require(directory.isDir) { "Expected a directory" }
+        val bookUrl = directory.toString()
+        val existing = appDb.bookDao.getBook(bookUrl)
+        val book = existing ?: Book(
+            type = BookType.local or BookType.image,
+            bookUrl = bookUrl,
+            name = directory.name,
+            author = "",
+            originName = directory.name,
+            origin = BookType.localTag,
+            latestChapterTime = directory.lastModified,
+            order = appDb.bookDao.minOrder - 1,
+        )
+        book.type = BookType.local or BookType.image
+        book.origin = BookType.localTag
+        book.originName = directory.name
+        book.latestChapterTime = directory.lastModified
+        book.upKind()
+        if (existing == null) appDb.bookDao.insert(book) else {
+            appDb.bookChapterDao.delByBook(bookUrl)
+            appDb.bookDao.update(book)
+        }
+        return book
+    }
+
     fun upBookInfo(book: Book) {
         when {
             book.isEpub -> EpubFile.upBookInfo(book)
@@ -314,11 +342,21 @@ object LocalBook {
         val books = mutableListOf<Book>()
         val fileDoc = FileDoc.fromUri(uri, false)
         if (ArchiveUtils.isArchive(fileDoc.name)) {
-            books.addAll(
-                importArchiveFile(uri) {
-                    it.matches(AppPattern.bookFileRegex)
+            val entries = ArchiveUtils.getArchiveFilesName(fileDoc)
+            val isComicArchive = entries.any { entry ->
+                entry.substringAfterLast('.', "").lowercase() in
+                        setOf("jpg", "jpeg", "png", "webp", "gif", "avif", "bmp")
+            }
+            if (isComicArchive) {
+                books += importFile(uri).apply {
+                    type = BookType.local or BookType.image or BookType.archive
+                    origin = BookType.localTag
+                    upKind()
+                    save()
                 }
-            )
+            } else {
+                books.addAll(importArchiveFile(uri) { it.matches(AppPattern.bookFileRegex) })
+            }
         } else {
             books.add(importFile(uri))
         }
@@ -330,13 +368,7 @@ object LocalBook {
         uris.forEach { uri ->
             val fileDoc = FileDoc.fromUri(uri, false)
             kotlin.runCatching {
-                if (ArchiveUtils.isArchive(fileDoc.name)) {
-                    importArchiveFile(uri) {
-                        it.matches(AppPattern.bookFileRegex)
-                    }
-                } else {
-                    importFile(uri)
-                }
+                importFiles(uri)
             }.onFailure {
                 AppLog.put("ImportFile Error:\nFile $fileDoc\n${it.localizedMessage}", it)
                 errorCount += 1

+ 11 - 0
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -87,6 +87,7 @@ private fun ImportBookContent(
     onSelectFolder: () -> Unit,
     onSelectBookFiles: () -> Unit,
     onScanFolder: () -> Unit,
+    onImportCurrentFolderAsManga: () -> Unit,
     onImportFileName: () -> Unit,
     onSortChange: (Int) -> Unit,
     onNavigateBack: () -> Unit,
@@ -116,6 +117,13 @@ private fun ImportBookContent(
             )
         },
         dropDownMenuContent = { dismiss ->
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.import_current_folder_as_manga),
+                onClick = {
+                    onImportCurrentFolderAsManga()
+                    dismiss()
+                },
+            )
             RoundDropdownMenuItem(
                 text = stringResource(R.string.sort_by_name),
                 onClick = {
@@ -406,6 +414,9 @@ fun ImportBookScreen(
         onSelectFolder = { onShowFolderPickerChange(true) },
         onSelectBookFiles = onSelectBookFiles,
         onScanFolder = { onIntent(ImportBookIntent.ScanFolder) },
+        onImportCurrentFolderAsManga = {
+            onIntent(ImportBookIntent.ImportCurrentFolderAsManga)
+        },
         onImportFileName = {
             onFileNameJsChange(state.fileNameRule)
             onShowImportFileNameDialogChange(true)

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

@@ -32,11 +32,11 @@ import io.legado.app.utils.isUri
 import io.legado.app.utils.list
 import io.legado.app.utils.mapParallel
 import io.legado.app.utils.takePersistablePermissionSafely
+import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
 import kotlinx.coroutines.delay
@@ -85,6 +85,7 @@ sealed interface ImportBookIntent {
     data class SearchQueryChange(val query: String) : ImportBookIntent
     data class SortChange(val sort: Int) : ImportBookIntent
     data object ScanFolder : ImportBookIntent
+    data object ImportCurrentFolderAsManga : ImportBookIntent
     data object NavigateBack : ImportBookIntent
     data class NavigateToLevel(val level: Int) : ImportBookIntent
     data object SelectAll : ImportBookIntent
@@ -164,6 +165,7 @@ class ImportBookViewModel(
             is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
             is ImportBookIntent.SortChange -> setSort(intent.sort)
             ImportBookIntent.ScanFolder -> scanCurrentDoc()
+            ImportBookIntent.ImportCurrentFolderAsManga -> importCurrentFolderAsManga()
             ImportBookIntent.NavigateBack -> navigateBack()
             is ImportBookIntent.NavigateToLevel -> navigateToLevel(intent.level)
             ImportBookIntent.SelectAll -> selectAllCheckable()
@@ -248,6 +250,20 @@ class ImportBookViewModel(
 
     fun hasRootDoc(): Boolean = _state.value.rootDoc != null
 
+    private fun importCurrentFolderAsManga() {
+        val state = _state.value
+        val directory = state.subDocs.lastOrNull() ?: state.rootDoc ?: return
+        execute { LocalBook.importMangaDirectory(directory) }
+            .onSuccess { _effects.tryEmit(ImportBookEffect.ShowToast("漫画目录已加入书架")) }
+            .onError {
+                _effects.tryEmit(
+                    ImportBookEffect.ShowToast(
+                        it.localizedMessage ?: "导入漫画目录失败"
+                    )
+                )
+            }
+    }
+
     private fun initialize() {
         val defaultPath = otherSettingsGateway.currentSettings.defaultBookTreeUri?.takeIf { it.isUri() }
         val effectiveDefaultPath = defaultPath ?: firstPersistedTreeUri()?.toString()

+ 44 - 5
app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt

@@ -7,17 +7,30 @@ import kotlinx.collections.immutable.toImmutableList
 @Immutable
 internal data class MangaPageSpread(
     val key: String,
-    val itemIndices: ImmutableList<Int>,
+    val slots: ImmutableList<MangaPageSlot>,
 ) {
+    val itemIndices: List<Int> get() = slots.map(MangaPageSlot::itemIndex)
     operator fun contains(itemIndex: Int): Boolean = itemIndex in itemIndices
 }
 
+@Immutable
+internal data class MangaPageSlot(
+    val itemIndex: Int,
+    val slice: MangaPageSlice = MangaPageSlice.FULL,
+)
+
+internal enum class MangaPageSlice { FULL, LEFT, RIGHT }
+
 internal fun buildMangaSpreads(
     items: List<MangaReaderItemUi>,
     doublePage: Boolean,
     aspectRatios: Map<String, Float> = emptyMap(),
+    coverSingle: Boolean = false,
+    shiftPairing: Boolean = false,
+    splitWidePages: Boolean = false,
+    splitRightToLeft: Boolean = false,
 ): List<MangaPageSpread> {
-    if (!doublePage) return items.indices.map { index -> items.singleSpread(index) }
+    if (!doublePage && !splitWidePages) return items.indices.map { index -> items.singleSpread(index) }
     val result = mutableListOf<MangaPageSpread>()
     var index = 0
     while (index < items.size) {
@@ -27,12 +40,31 @@ internal fun buildMangaSpreads(
             aspectRatios[first.key]?.let { it > 1f } == true
         val secondIsWide = second is MangaReaderItemUi.Page &&
             aspectRatios[second.key]?.let { it > 1f } == true
-        if (first is MangaReaderItemUi.Page && second is MangaReaderItemUi.Page &&
+        if (firstIsWide && splitWidePages) {
+            val slices = if (splitRightToLeft) {
+                listOf(MangaPageSlice.RIGHT, MangaPageSlice.LEFT)
+            } else {
+                listOf(MangaPageSlice.LEFT, MangaPageSlice.RIGHT)
+            }
+            slices.forEach { slice ->
+                result += MangaPageSpread(
+                    key = "spread:${first.key}:$slice",
+                    slots = listOf(MangaPageSlot(index, slice)).toImmutableList(),
+                )
+            }
+            index++
+            continue
+        }
+        val firstMustStaySingle = first is MangaReaderItemUi.Page &&
+                ((coverSingle && first.pageIndex == 0) ||
+                        (shiftPairing && previousPageIsDifferentChapter(items, index)))
+        if (doublePage && !firstMustStaySingle &&
+            first is MangaReaderItemUi.Page && second is MangaReaderItemUi.Page &&
             first.chapterIndex == second.chapterIndex && !firstIsWide && !secondIsWide
         ) {
             result += MangaPageSpread(
                 key = "spread:${first.key}|${second.key}",
-                itemIndices = listOf(index, index + 1).toImmutableList(),
+                slots = listOf(MangaPageSlot(index), MangaPageSlot(index + 1)).toImmutableList(),
             )
             index += 2
         } else {
@@ -43,7 +75,14 @@ internal fun buildMangaSpreads(
     return result
 }
 
+private fun previousPageIsDifferentChapter(items: List<MangaReaderItemUi>, index: Int): Boolean {
+    val current = items.getOrNull(index) as? MangaReaderItemUi.Page ?: return false
+    val previous = items.subList(0, index).lastOrNull { it is MangaReaderItemUi.Page }
+            as? MangaReaderItemUi.Page
+    return previous?.chapterIndex != current.chapterIndex
+}
+
 private fun List<MangaReaderItemUi>.singleSpread(index: Int) = MangaPageSpread(
     key = "spread:${get(index).key}",
-    itemIndices = listOf(index).toImmutableList(),
+    slots = listOf(MangaPageSlot(index)).toImmutableList(),
 )

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

@@ -30,6 +30,7 @@ data class MangaReaderUiState(
     val pageCount: Int = 0,
     val chapterIndex: Int = 0,
     val chapterCount: Int = 0,
+    val cacheAvailable: Boolean = false,
     val isLoading: Boolean = true,
     val isChapterLoading: Boolean = false,
     val pendingChapterIndex: Int? = null,
@@ -61,6 +62,8 @@ sealed interface MangaReaderItemUi {
         val pageIndex: Int,
         val pageCount: Int,
         val chapterName: String,
+        val loadState: MangaPageLoadState = MangaPageLoadState.Queued,
+        val retryRevision: Int = 0,
     ) : MangaReaderItemUi
 
     @Stable
@@ -69,6 +72,7 @@ sealed interface MangaReaderItemUi {
         val message: String,
         val loading: Boolean = false,
         val retryChapterIndex: Int? = null,
+        val fullScreen: Boolean = false,
     ) : MangaReaderItemUi
 
     /** A deliberate boundary between chapters; it is never a page from another chapter. */
@@ -85,6 +89,14 @@ sealed interface MangaReaderItemUi {
     ) : MangaReaderItemUi
 }
 
+@Stable
+sealed interface MangaPageLoadState {
+    data object Queued : MangaPageLoadState
+    data object Loading : MangaPageLoadState
+    data object Ready : MangaPageLoadState
+    data class Failed(val message: String?) : MangaPageLoadState
+}
+
 @Stable
 enum class MangaChapterTransitionDirection { PREVIOUS, NEXT }
 
@@ -101,12 +113,16 @@ data class MangaReaderSettings(
     val zoomStartPosition: Int = 0,
     val widePageMode: Int = 0,
     val doublePageMode: Int = 0,
+    val doublePageCoverSingle: Boolean = true,
+    val doublePageInvert: Boolean = false,
+    val doublePageShift: Boolean = false,
     val disableScale: Boolean = true,
     val disableScrollAnimation: Boolean = false,
     val disableCrossFade: Boolean = false,
     val disableClickScroll: Boolean = false,
     val longPressEnabled: Boolean = true,
     val preDownloadCount: Int = 10,
+    val chapterPrefetchCount: Int = 1,
     val autoReadSpeed: Int = 3,
     val volumeKeyPage: Boolean = false,
     val reverseVolumeKeyPage: Boolean = false,
@@ -203,6 +219,8 @@ sealed interface MangaReaderIntent {
     data class OpenSettings(val category: MangaReaderSettingsCategory) : MangaReaderIntent
     data object CloseSettings : MangaReaderIntent
     data object OpenSourceActions : MangaReaderIntent
+    data object OpenCacheActions : MangaReaderIntent
+    data class CacheChapters(val selection: MangaCacheSelection) : MangaReaderIntent
     data object ToggleAutoRead : MangaReaderIntent
     data object DismissSheet : MangaReaderIntent
     data class UpdateSetting(
@@ -212,6 +230,11 @@ sealed interface MangaReaderIntent {
     data class UpdateMenuPaletteStyle(val value: String) : MangaReaderIntent
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class RetryChapter(val chapterIndex: Int) : MangaReaderIntent
+    data class PageLoadStarted(val key: String) : MangaReaderIntent
+    data class PageLoadSucceeded(val key: String) : MangaReaderIntent
+    data class PageLoadFailed(val key: String, val message: String?) : MangaReaderIntent
+    data class RetryPage(val key: String) : MangaReaderIntent
+    data class RetryFailedPagesInChapter(val chapterIndex: Int) : MangaReaderIntent
     data class PageStep(val direction: Int) : MangaReaderIntent
     data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
     data class VisibleItemChanged(
@@ -222,7 +245,13 @@ sealed interface MangaReaderIntent {
         val navigationId: Long,
     ) : MangaReaderIntent
     data class PagerScrollChanged(val inProgress: Boolean) : MangaReaderIntent
-    data class LongPressPage(val imageUrl: String) : MangaReaderIntent
+    data class LongPressPage(
+        val pageKey: String,
+        val companionPageKey: String? = null,
+        val companionBeforePage: Boolean = false,
+    ) : MangaReaderIntent
+
+    data class ExecutePageAction(val action: MangaPageAction) : MangaReaderIntent
     data class MessageShown(val id: Long) : MangaReaderIntent
 }
 
@@ -230,8 +259,18 @@ sealed interface MangaReaderSheet {
     data object Catalog : MangaReaderSheet
     data object ChangeSource : MangaReaderSheet
     data object SourceActions : MangaReaderSheet
+    data object CacheActions : MangaReaderSheet
+    data class PageActions(
+        val pageKey: String,
+        val companionPageKey: String? = null,
+        val companionBeforePage: Boolean = false,
+    ) : MangaReaderSheet
 }
 
+enum class MangaPageAction { SAVE, SAVE_SPREAD, SHARE, SHARE_SPREAD, COPY, COPY_SPREAD, SET_COVER }
+
+enum class MangaCacheSelection { CURRENT, FOLLOWING, ALL }
+
 enum class MangaReaderSettingsCategory { READER, FOOTER, MENU, FILTER, CLICK_ACTIONS, AUTO_READ }
 
 sealed interface MangaReaderDialog {
@@ -251,12 +290,16 @@ enum class MangaReaderSettingKey {
     ZOOM_START_POSITION,
     WIDE_PAGE_MODE,
     DOUBLE_PAGE_MODE,
+    DOUBLE_PAGE_COVER_SINGLE,
+    DOUBLE_PAGE_INVERT,
+    DOUBLE_PAGE_SHIFT,
     DISABLE_SCALE,
     DISABLE_SCROLL_ANIMATION,
     DISABLE_CROSS_FADE,
     DISABLE_CLICK_SCROLL,
     LONG_PRESS,
     PRE_DOWNLOAD,
+    CHAPTER_PREFETCH,
     AUTO_READ_SPEED,
     VOLUME_KEY_PAGE,
     REVERSE_VOLUME_KEY_PAGE,
@@ -302,4 +345,6 @@ sealed interface MangaReaderEffect {
     ) : MangaReaderEffect
     data class SetWindowBrightness(val auto: Boolean, val brightness: Int) : MangaReaderEffect
     data class SetSystemBarsVisible(val visible: Boolean) : MangaReaderEffect
+    data class ShareImage(val filePath: String) : MangaReaderEffect
+    data class CopyImage(val filePath: String) : MangaReaderEffect
 }

+ 12 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt

@@ -1,5 +1,17 @@
 package io.legado.app.ui.book.manga
 
+internal fun mangaChapterLoadingItem(
+    chapterIndex: Int,
+    message: String,
+    failed: Boolean,
+) = MangaReaderItemUi.ChapterEdge(
+    key = "chapter-placeholder:$chapterIndex:${if (failed) "failed" else "loading"}",
+    message = message,
+    loading = !failed,
+    retryChapterIndex = chapterIndex.takeIf { failed },
+    fullScreen = true,
+)
+
 internal fun mangaClickRegionIndex(
     x: Float,
     y: Float,

+ 25 - 9
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -39,6 +39,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.automirrored.filled.MenuBook
 import androidx.compose.material.icons.filled.AutoMode
+import androidx.compose.material.icons.filled.CloudDownload
 import androidx.compose.material.icons.filled.FilterAlt
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
@@ -453,10 +454,13 @@ private fun MangaMenuBottomBar(
                 if (isSettings) {
                     MangaSettingsPanel(state, onIntent)
                 } else {
-                    Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
+                    Column(Modifier
+                        .fillMaxWidth()
+                        .padding(vertical = 12.dp)) {
                         if (state.pageCount > 1) {
                             Row(
-                                modifier = Modifier.fillMaxWidth()
+                                modifier = Modifier
+                                    .fillMaxWidth()
                                     .padding(horizontal = 16.dp),
                                 verticalAlignment = Alignment.CenterVertically,
                                 horizontalArrangement = Arrangement.spacedBy(16.dp),
@@ -492,13 +496,21 @@ private fun MangaMenuBottomBar(
                         }
                         Spacer(Modifier.height(12.dp))
                         MangaMenuToolRow(
-                            listOf(
+                            buildList {
+                                add(
                                 ReaderMenuAction(
                                     Icons.AutoMirrored.Filled.MenuBook,
                                     stringResource(R.string.chapter_list)
                                 ) {
                                     onIntent(MangaReaderIntent.OpenCatalog)
-                                },
+                                })
+                                if (state.cacheAvailable) add(
+                                    ReaderMenuAction(
+                                        Icons.Filled.CloudDownload,
+                                        stringResource(R.string.offline_cache),
+                                    ) { onIntent(MangaReaderIntent.OpenCacheActions) }
+                                )
+                                add(
                                 ReaderMenuAction(
                                     icon = Icons.Filled.AutoMode,
                                     description = if (state.autoReadEnabled) {
@@ -514,7 +526,9 @@ private fun MangaMenuBottomBar(
                                             )
                                         )
                                     },
-                                ),
+                                )
+                                )
+                                add(
                                 ReaderMenuAction(
                                     Icons.Filled.Tune,
                                     stringResource(R.string.manga_reader_page_settings)
@@ -524,7 +538,8 @@ private fun MangaMenuBottomBar(
                                             MangaReaderSettingsCategory.READER
                                         )
                                     )
-                                },
+                                })
+                                add(
                                 ReaderMenuAction(
                                     Icons.Filled.FilterAlt,
                                     stringResource(R.string.manga_reader_filter_short)
@@ -534,7 +549,8 @@ private fun MangaMenuBottomBar(
                                             MangaReaderSettingsCategory.FILTER
                                         )
                                     )
-                                },
+                                })
+                                add(
                                 ReaderMenuAction(
                                     Icons.Filled.TouchApp,
                                     stringResource(R.string.manga_reader_click_area_short)
@@ -544,8 +560,8 @@ private fun MangaMenuBottomBar(
                                             MangaReaderSettingsCategory.CLICK_ACTIONS
                                         )
                                     )
-                                },
-                            ),
+                                })
+                            },
                             glassEnabled = glassOnControls,
                             backdrop = backdrop,
                         )

+ 17 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt

@@ -8,17 +8,18 @@ import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
-import dev.chrisbanes.haze.HazeState
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import dev.chrisbanes.haze.HazeState
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.ReaderBookSheetTab
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.openUrl
+import io.legado.app.utils.share
 import io.legado.app.utils.toggleSystemBar
 import kotlinx.coroutines.flow.collectLatest
 
@@ -96,6 +97,21 @@ fun MangaReaderRouteScreen(
                     }
                 }
                 is MangaReaderEffect.SetSystemBarsVisible -> activity.toggleSystemBar(effect.visible)
+                is MangaReaderEffect.ShareImage -> activity.share(
+                    java.io.File(effect.filePath),
+                    "image/jpeg"
+                )
+
+                is MangaReaderEffect.CopyImage -> {
+                    val file = java.io.File(effect.filePath)
+                    val uri = androidx.core.content.FileProvider.getUriForFile(
+                        activity, io.legado.app.constant.AppConst.authority, file,
+                    )
+                    activity.getSystemService(android.content.ClipboardManager::class.java)
+                        .setPrimaryClip(
+                            android.content.ClipData.newUri(activity.contentResolver, "manga", uri)
+                        )
+                }
             }
         }
     }

+ 154 - 73
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -11,13 +11,11 @@ import androidx.compose.foundation.gestures.awaitFirstDown
 import androidx.compose.foundation.gestures.calculateCentroid
 import androidx.compose.foundation.gestures.calculatePan
 import androidx.compose.foundation.gestures.calculateZoom
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.gestures.rememberTransformableState
 import androidx.compose.foundation.gestures.scrollBy
-import androidx.compose.foundation.gestures.transformable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
@@ -25,7 +23,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.requiredWidth
 import androidx.compose.foundation.layout.widthIn
 import androidx.compose.foundation.lazy.LazyColumn
@@ -33,8 +30,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
@@ -52,15 +49,16 @@ import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.isSpecified
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.ColorMatrix
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.layout
 import androidx.compose.ui.layout.onGloballyPositioned
@@ -71,8 +69,8 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.IntSize
 import androidx.compose.ui.unit.constrainHeight
 import androidx.compose.ui.unit.constrainWidth
 import androidx.compose.ui.unit.dp
@@ -80,33 +78,32 @@ import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.repeatOnLifecycle
 import coil3.ImageLoader
-import coil3.toBitmap
-import coil3.request.ImageRequest
 import coil3.request.CachePolicy
+import coil3.request.ImageRequest
 import coil3.request.allowHardware
 import coil3.request.crossfade
 import coil3.request.transformations
+import coil3.toBitmap
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
 import io.legado.app.help.coil.CoverExtras
-import io.legado.app.help.coil.CoverFetcher
-import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.book.manga.config.MangaDoublePageMode
 import io.legado.app.ui.book.manga.config.MangaPageScaleType
+import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.book.manga.config.MangaWidePageMode
 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.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
-import me.saket.telephoto.zoomable.EnabledZoomGestures
+import kotlinx.coroutines.launch
 import me.saket.telephoto.zoomable.DoubleClickToZoomListener
+import me.saket.telephoto.zoomable.EnabledZoomGestures
 import me.saket.telephoto.zoomable.ZoomSpec
 import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
 import me.saket.telephoto.zoomable.rememberZoomableImageState
@@ -208,25 +205,38 @@ fun MangaReaderScreen(
             onIntent(MangaReaderIntent.MessageShown(message.id))
         }
     }
+    val pagePrefetchRevision = state.pages.filterIsInstance<MangaReaderItemUi.Page>()
+        .map { it.key to it.retryRevision }
     DisposableEffect(
         state.currentItemIndex,
-        state.pages,
+        pagePrefetchRevision,
         state.settings.preDownloadCount,
         state.settings.sourceOrigin,
         state.settings.enableEInk,
         state.settings.enableGray,
     ) {
         val ahead = state.settings.preDownloadCount.coerceIn(0, 10)
-        val start = (state.currentItemIndex - 2).coerceAtLeast(0)
-        val end = (state.currentItemIndex + ahead + 1).coerceAtMost(state.pages.size)
-        val requests = if (ahead == 0) emptyList() else state.pages.subList(start, end)
+        val current = state.currentItemIndex
+        val prioritizedIndices = buildList {
+            add(current)
+            addAll((current + 1..current + ahead).filter { it in state.pages.indices })
+            addAll((current - 1 downTo current - 2).filter { it in state.pages.indices })
+        }.distinct()
+        val requests = if (ahead == 0) emptyList() else prioritizedIndices
+            .mapNotNull(state.pages::getOrNull)
             .filterIsInstance<MangaReaderItemUi.Page>()
+            .filterNot { it.loadState == MangaPageLoadState.Ready }
             .map { page ->
                 imageLoader.enqueue(
                     page.imageRequest(
                         settings = state.settings,
                         context = context,
                         onAspectRatio = { aspectRatios[page.key] = it },
+                        onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
+                        onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
+                        onError = { message ->
+                            onIntent(MangaReaderIntent.PageLoadFailed(page.key, message))
+                        },
                     ).newBuilder()
                         // Keep prefetched pages available to the reader request. Disabling this
                         // caused a slider jump to decode the same image again from scratch.
@@ -290,6 +300,12 @@ fun MangaReaderScreen(
     if (state.activeSheet == MangaReaderSheet.SourceActions) {
         MangaReaderSourceActionsSheet(state, onIntent)
     }
+    if (state.activeSheet == MangaReaderSheet.CacheActions) {
+        MangaReaderCacheActionsSheet(state, onIntent)
+    }
+    (state.activeSheet as? MangaReaderSheet.PageActions)?.let { sheet ->
+        MangaReaderPageActionsSheet(sheet.companionPageKey != null, onIntent)
+    }
     if (state.activeSheet == MangaReaderSheet.ChangeSource) {
         val oldBook = remember(state.changeSourceBook) {
             state.changeSourceBook?.toBook()
@@ -568,19 +584,20 @@ private fun WebtoonMangaList(
                         state.settings.longPressEnabled
                     ) {
                         pendingWebtoonTap?.cancel()
-                        val visibleItem = listState.layoutInfo.visibleItemsInfo.firstOrNull { info ->
-                            latest.y >= info.offset && latest.y < info.offset + info.size
-                        }
+                        val visibleItem =
+                            listState.layoutInfo.visibleItemsInfo.firstOrNull { info ->
+                                latest.y >= info.offset && latest.y < info.offset + info.size
+                            }
                         val page = visibleItem?.let { state.pages.getOrNull(it.index) }
-                            as? MangaReaderItemUi.Page
-                        page?.let { onIntent(MangaReaderIntent.LongPressPage(it.imageUrl)) }
+                                as? MangaReaderItemUi.Page
+                        page?.let { onIntent(MangaReaderIntent.LongPressPage(it.key)) }
                         return@awaitEachGesture
                     }
 
                     val isDoubleTap = !state.settings.disableScale &&
-                        upAt - lastWebtoonTapAt <= viewConfiguration.doubleTapTimeoutMillis &&
-                        lastWebtoonTapPosition.isSpecified &&
-                        (latest - lastWebtoonTapPosition).getDistance() <= viewConfiguration.touchSlop * 2
+                            upAt - lastWebtoonTapAt <= viewConfiguration.doubleTapTimeoutMillis &&
+                            lastWebtoonTapPosition.isSpecified &&
+                            (latest - lastWebtoonTapPosition).getDistance() <= viewConfiguration.touchSlop * 2
                     if (isDoubleTap) {
                         pendingWebtoonTap?.cancel()
                         pendingWebtoonTap = null
@@ -645,8 +662,18 @@ private fun HorizontalMangaPager(
     val aspectRatios = LocalMangaAspectRatios.current
     val useDoublePage = isDoublePageActive(state.settings.doublePageMode, viewport)
     val aspectRatioSnapshot = aspectRatios.toMap()
-    val spreads = remember(state.pages, useDoublePage, aspectRatioSnapshot) {
-        buildMangaSpreads(state.pages, useDoublePage, aspectRatioSnapshot)
+    val spreads = remember(state.pages, useDoublePage, aspectRatioSnapshot, state.settings) {
+        buildMangaSpreads(
+            state.pages,
+            useDoublePage,
+            aspectRatioSnapshot,
+            coverSingle = state.settings.doublePageCoverSingle,
+            shiftPairing = state.settings.doublePageShift,
+            splitWidePages = state.settings.widePageMode == MangaWidePageMode.SPLIT,
+            splitRightToLeft =
+                (state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT) xor
+                        state.settings.doublePageInvert,
+        )
     }
     val initialSpread = spreads.indexOfFirst { state.currentItemIndex in it }.coerceAtLeast(0)
     val pagerState = rememberPagerState(
@@ -728,9 +755,9 @@ private fun HorizontalMangaPager(
                     ) return@awaitEachGesture
 
                     val isDoubleTap = !state.settings.disableScale &&
-                        upAt - lastTapAt <= viewConfiguration.doubleTapTimeoutMillis &&
-                        lastTapPosition.isSpecified &&
-                        (latest - lastTapPosition).getDistance() <= viewConfiguration.touchSlop * 2
+                            upAt - lastTapAt <= viewConfiguration.doubleTapTimeoutMillis &&
+                            lastTapPosition.isSpecified &&
+                            (latest - lastTapPosition).getDistance() <= viewConfiguration.touchSlop * 2
                     if (isDoubleTap) {
                         pendingTap?.cancel()
                         pendingTap = null
@@ -755,12 +782,14 @@ private fun HorizontalMangaPager(
                 }
             },
     ) { page ->
-        val indices = spreads.getOrNull(page)?.itemIndices.orEmpty()
-        val displayIndices = if (
-            indices.size == 2 && state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
-        ) indices.reversed() else indices
+        val slots = spreads.getOrNull(page)?.slots.orEmpty()
+        val indices = slots.map(MangaPageSlot::itemIndex)
+        val reverseSpread =
+            (state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT) xor
+                    state.settings.doublePageInvert
+        val displaySlots = if (slots.size == 2 && reverseSpread) slots.reversed() else slots
         MangaHorizontalSpread(
-            indices = displayIndices,
+            slots = displaySlots,
             state = state,
             onIntent = onIntent,
             imageLoader = imageLoader,
@@ -771,21 +800,24 @@ private fun HorizontalMangaPager(
 
 @Composable
 private fun MangaHorizontalSpread(
-    indices: List<Int>,
+    slots: List<MangaPageSlot>,
     state: MangaReaderUiState,
     onIntent: (MangaReaderIntent) -> Unit,
     imageLoader: ImageLoader,
     viewportHandlesClicks: Boolean,
 ) {
     val viewport = LocalReaderViewportSize.current
-    if (indices.size < 2) {
-        state.pages.getOrNull(indices.firstOrNull() ?: -1)?.let {
+    if (slots.size < 2) {
+        val slot = slots.firstOrNull()
+        state.pages.getOrNull(slot?.itemIndex ?: -1)?.let {
             MangaReaderItem(
                 it,
                 state.settings,
                 onIntent,
                 imageLoader,
-                Modifier.fillMaxSize(),
+                Modifier
+                    .fillMaxSize()
+                    .pageSlice(slot?.slice ?: MangaPageSlice.FULL),
                 paged = true,
                 viewportHandlesClicks = viewportHandlesClicks,
             )
@@ -836,21 +868,34 @@ private fun MangaHorizontalSpread(
                 },
                 onLongClick = if (state.settings.longPressEnabled) { tap ->
                     val slot = if (tap.x < spreadSize.value.width / 2f) 0 else 1
-                    val page = indices.getOrNull(slot)?.let(state.pages::getOrNull)
-                        as? MangaReaderItemUi.Page
-                    page?.let { onIntent(MangaReaderIntent.LongPressPage(it.imageUrl)) }
+                    val page = slots.getOrNull(slot)?.itemIndex?.let(state.pages::getOrNull)
+                            as? MangaReaderItemUi.Page
+                    page?.let {
+                        val companion = slots.getOrNull(if (slot == 0) 1 else 0)
+                            ?.itemIndex?.let(state.pages::getOrNull) as? MangaReaderItemUi.Page
+                        onIntent(
+                            MangaReaderIntent.LongPressPage(
+                                it.key,
+                                companion?.takeIf { other -> other.key != it.key }?.key,
+                                companionBeforePage = slot == 1,
+                            )
+                        )
+                    }
                 } else null,
                 onDoubleClick = doubleClickToZoom,
             ),
     ) {
-            indices.forEach { itemIndex ->
-                state.pages.getOrNull(itemIndex)?.let {
+        slots.forEach { slot ->
+            state.pages.getOrNull(slot.itemIndex)?.let {
                     MangaReaderItem(
                         it,
                         state.settings,
                         onIntent,
                         imageLoader,
-                        Modifier.weight(1f).fillMaxHeight(),
+                        Modifier
+                            .weight(1f)
+                            .fillMaxHeight()
+                            .pageSlice(slot.slice),
                         paged = true,
                         pageInteractions = false,
                     )
@@ -859,6 +904,19 @@ private fun MangaHorizontalSpread(
     }
 }
 
+private fun Modifier.pageSlice(slice: MangaPageSlice): Modifier = when (slice) {
+    MangaPageSlice.FULL -> this
+    MangaPageSlice.LEFT, MangaPageSlice.RIGHT -> clipToBounds().layout { measurable, constraints ->
+        val width = constraints.maxWidth
+        val placeable = measurable.measure(
+            constraints.copy(minWidth = width * 2, maxWidth = width * 2),
+        )
+        layout(width, placeable.height) {
+            placeable.placeRelative(if (slice == MangaPageSlice.LEFT) 0 else -width, 0)
+        }
+    }
+}
+
 private fun Modifier.rotateWidePage(enabled: Boolean): Modifier = if (!enabled) this else {
     layout { measurable, constraints ->
         val placeable = measurable.measure(
@@ -949,7 +1007,12 @@ private fun MangaReaderItem(
         )
         is MangaReaderItemUi.ChapterEdge -> Box(
             modifier = modifier
-                .then(if (paged) Modifier.fillMaxHeight() else Modifier.height(96.dp)),
+                .then(
+                    if (paged) Modifier.fillMaxHeight()
+                    else if (item.fullScreen) {
+                        Modifier.height(LocalConfiguration.current.screenHeightDp.dp)
+                    } else Modifier.height(96.dp)
+                ),
             contentAlignment = Alignment.Center,
         ) {
             Column(
@@ -1066,8 +1129,6 @@ private fun MangaPageImage(
 ) {
     var positionInRoot by remember(page.key) { mutableStateOf(Offset.Zero) }
     var imageViewportSize by remember(page.key) { mutableStateOf(IntSize.Zero) }
-    var retryToken by remember(page.key) { mutableStateOf(0) }
-    var loadFailed by remember(page.key) { mutableStateOf(false) }
     val viewportSize = LocalReaderViewportSize.current
     val viewportOrigin = LocalReaderViewportOrigin.current
     val aspectRatios = LocalMangaAspectRatios.current
@@ -1085,7 +1146,7 @@ private fun MangaPageImage(
         settings.eInkThreshold,
         settings.enableGray,
         settings.disableCrossFade,
-        retryToken,
+        page.retryRevision,
     ) {
         listOf(
             page.key,
@@ -1094,7 +1155,7 @@ private fun MangaPageImage(
             settings.eInkThreshold,
             settings.enableGray,
             settings.disableCrossFade,
-            retryToken,
+            page.retryRevision,
         )
     }
     val request = remember(imagePipelineKey) {
@@ -1102,7 +1163,11 @@ private fun MangaPageImage(
             settings = settings,
             context = context,
             onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
-            onError = { loadFailed = true },
+            onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
+            onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
+            onError = { message ->
+                onIntent(MangaReaderIntent.PageLoadFailed(page.key, message))
+            },
         )
     }
     val imageRatio = aspectRatios[page.key]
@@ -1219,15 +1284,15 @@ private fun MangaPageImage(
                     )
                 } else null,
                 onLongClick = if (interactionsEnabled && settings.longPressEnabled) { _ ->
-                    onIntent(MangaReaderIntent.LongPressPage(page.imageUrl))
+                    onIntent(MangaReaderIntent.LongPressPage(page.key))
                 } else null,
                 modifier = Modifier.fillMaxSize(),
             )
-            MangaImageRetryOverlay(loadFailed) {
-                loadFailed = false
-                CoverFetcher.clearFailure(page.imageUrl)
-                retryToken++
-            }
+            MangaImageLoadOverlay(
+                page.loadState,
+                { onIntent(MangaReaderIntent.RetryPage(page.key)) },
+                { onIntent(MangaReaderIntent.RetryFailedPagesInChapter(page.chapterIndex)) },
+            )
         }
         return
     }
@@ -1243,27 +1308,38 @@ private fun MangaPageImage(
             colorFilter = mangaColorFilter(settings),
             modifier = Modifier.fillMaxSize(),
         )
-        MangaImageRetryOverlay(loadFailed) {
-            loadFailed = false
-            CoverFetcher.clearFailure(page.imageUrl)
-            retryToken++
-        }
+        MangaImageLoadOverlay(
+            page.loadState,
+            { onIntent(MangaReaderIntent.RetryPage(page.key)) },
+            { onIntent(MangaReaderIntent.RetryFailedPagesInChapter(page.chapterIndex)) },
+        )
     }
 }
 
 @Composable
-private fun MangaImageRetryOverlay(failed: Boolean, onRetry: () -> Unit) {
-    if (!failed) return
+private fun MangaImageLoadOverlay(
+    loadState: MangaPageLoadState,
+    onRetry: () -> Unit,
+    onRetryChapter: () -> Unit,
+) {
+    if (loadState is MangaPageLoadState.Ready) return
     Box(
         modifier = Modifier
             .fillMaxSize()
             .background(Color.Black.copy(alpha = 0.55f)),
         contentAlignment = Alignment.Center,
     ) {
-        MediumOutlinedButton(
-            onClick = onRetry,
-            text = stringResource(R.string.retry),
-        )
+        if (loadState is MangaPageLoadState.Failed) {
+            Column(horizontalAlignment = Alignment.CenterHorizontally) {
+                MediumOutlinedButton(onClick = onRetry, text = stringResource(R.string.retry))
+                MediumOutlinedButton(
+                    onClick = onRetryChapter,
+                    text = stringResource(R.string.manga_reader_retry_failed_chapter),
+                )
+            }
+        } else {
+            androidx.compose.material3.CircularProgressIndicator()
+        }
     }
 }
 
@@ -1271,7 +1347,9 @@ private fun MangaReaderItemUi.Page.imageRequest(
     settings: MangaReaderSettings,
     context: android.content.Context,
     onAspectRatio: (Float) -> Unit = {},
-    onError: () -> Unit = {},
+    onStart: () -> Unit = {},
+    onSuccess: () -> Unit = {},
+    onError: (String?) -> Unit = {},
 ): ImageRequest {
     val memoryCacheKey = "manga-page:$bookUrl:$imageUrl:${settings.sourceOrigin}:" +
         "${settings.enableEInk}:${settings.eInkThreshold}:${settings.enableGray}"
@@ -1295,11 +1373,14 @@ private fun MangaReaderItemUi.Page.imageRequest(
             }
             crossfade(!settings.disableCrossFade)
         }
-        .listener(onError = { _, _ -> onError() }, onSuccess = { _, result ->
+        .listener(onStart = { _ -> onStart() }, onError = { _, result ->
+            onError(result.throwable.localizedMessage)
+        }, onSuccess = { _, result ->
             val image = result.image
             if (image.width > 0 && image.height > 0) {
                 onAspectRatio(image.width.toFloat() / image.height)
             }
+            onSuccess()
         })
         .build()
 }

+ 152 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt

@@ -10,9 +10,16 @@ import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.Login
+import androidx.compose.material.icons.filled.CloudDownload
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.DownloadForOffline
 import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Image
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.ShoppingCart
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.runtime.Composable
@@ -71,7 +78,9 @@ internal fun MangaReaderSourceActionsSheet(
             MangaMoreAction(stringResource(R.string.edit_source), Icons.Default.Edit, MangaReaderIntent.OpenSourceEdit),
             MangaMoreAction(stringResource(R.string.disable_source), Icons.Default.MoreVert, MangaReaderIntent.DisableCurrentSource),
         )
-        Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
+        Column(Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp)) {
             actions.chunked(4).forEach { rowActions ->
                 Row(
                     modifier = Modifier.fillMaxWidth(),
@@ -93,3 +102,145 @@ internal fun MangaReaderSourceActionsSheet(
         Spacer(Modifier.height(8.dp))
     }
 }
+
+@Composable
+internal fun MangaReaderPageActionsSheet(
+    hasSpread: Boolean,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    AppModalBottomSheet(
+        show = true,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
+        contentPaddingEnabled = false,
+    ) {
+        val actions = buildList {
+            add(
+                MangaMoreAction(
+                    stringResource(R.string.save_image), Icons.Default.Save,
+                    MangaReaderIntent.ExecutePageAction(MangaPageAction.SAVE)
+                )
+            )
+            add(
+                MangaMoreAction(
+                    stringResource(R.string.share), Icons.Default.Share,
+                    MangaReaderIntent.ExecutePageAction(MangaPageAction.SHARE)
+                )
+            )
+            add(
+                MangaMoreAction(
+                    stringResource(R.string.copy_text), Icons.Default.ContentCopy,
+                    MangaReaderIntent.ExecutePageAction(MangaPageAction.COPY)
+                )
+            )
+            add(
+                MangaMoreAction(
+                    stringResource(R.string.manga_reader_set_cover), Icons.Default.Image,
+                    MangaReaderIntent.ExecutePageAction(MangaPageAction.SET_COVER)
+                )
+            )
+            if (hasSpread) {
+                add(
+                    MangaMoreAction(
+                        stringResource(R.string.manga_reader_save_spread), Icons.Default.Save,
+                        MangaReaderIntent.ExecutePageAction(MangaPageAction.SAVE_SPREAD)
+                    )
+                )
+                add(
+                    MangaMoreAction(
+                        stringResource(R.string.manga_reader_share_spread), Icons.Default.Share,
+                        MangaReaderIntent.ExecutePageAction(MangaPageAction.SHARE_SPREAD)
+                    )
+                )
+                add(
+                    MangaMoreAction(
+                        stringResource(R.string.manga_reader_copy_spread),
+                        Icons.Default.ContentCopy,
+                        MangaReaderIntent.ExecutePageAction(MangaPageAction.COPY_SPREAD)
+                    )
+                )
+            }
+        }
+        Column(Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp)) {
+            actions.chunked(4).forEach { rowActions ->
+                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    rowActions.forEach { action ->
+                        ReaderMenuActionSquare(
+                            action.icon,
+                            action.label,
+                            Modifier.weight(1f),
+                            onClick = { onIntent(action.intent) },
+                        )
+                    }
+                    repeat(4 - rowActions.size) { Spacer(Modifier.weight(1f)) }
+                }
+                Spacer(Modifier.height(8.dp))
+            }
+        }
+    }
+}
+
+@Composable
+internal fun MangaReaderCacheActionsSheet(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    AppModalBottomSheet(
+        show = true,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
+        contentPaddingEnabled = false,
+    ) {
+        val actions = listOf(
+            MangaMoreAction(
+                stringResource(R.string.manga_reader_cache_current),
+                Icons.Default.Download,
+                MangaReaderIntent.CacheChapters(MangaCacheSelection.CURRENT),
+            ),
+            MangaMoreAction(
+                stringResource(R.string.manga_reader_cache_following),
+                Icons.Default.CloudDownload,
+                MangaReaderIntent.CacheChapters(MangaCacheSelection.FOLLOWING),
+            ),
+            MangaMoreAction(
+                stringResource(R.string.manga_reader_cache_all),
+                Icons.Default.DownloadForOffline,
+                MangaReaderIntent.CacheChapters(MangaCacheSelection.ALL),
+            ),
+        )
+        Box(modifier = Modifier.padding(horizontal = 16.dp)) {
+            ReaderBookHeader(
+                state = ReaderBookHeaderState(
+                    bookUrl = state.bookUrl,
+                    sourceUrl = state.sourceUrl.orEmpty(),
+                    sourceName = state.sourceName,
+                    title = state.bookName,
+                    author = state.bookAuthor,
+                    coverUrl = state.coverUrl,
+                    customCoverUrl = state.customCoverUrl,
+                    chapterTitle = state.chapterName,
+                    chapterIndex = state.chapterIndex,
+                    chapterCount = state.chapterCount,
+                ),
+            )
+        }
+        Spacer(Modifier.height(16.dp))
+        Row(
+            Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            actions.forEach { action ->
+                ReaderMenuActionSquare(
+                    icon = action.icon,
+                    text = action.label,
+                    modifier = Modifier.weight(1f),
+                    onClick = { onIntent(action.intent) },
+                )
+            }
+            Spacer(Modifier.weight(1f))
+        }
+        Spacer(Modifier.height(8.dp))
+    }
+}

+ 256 - 16
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -8,21 +8,22 @@ import io.legado.app.R
 import io.legado.app.data.model.MangaFooterConfig
 import io.legado.app.data.repository.manga.MangaReaderActionPaymentResult
 import io.legado.app.data.repository.manga.MangaReaderActionRepository
-import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.MangaReaderSessionFactory
+import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.model.manga.MangaChapterState
 import io.legado.app.domain.model.manga.MangaProgressState
 import io.legado.app.domain.model.manga.MangaSessionCommand
 import io.legado.app.domain.model.manga.MangaSessionEvent
 import io.legado.app.domain.model.manga.MangaSessionState
+import io.legado.app.domain.model.settings.MangaSettings
+import io.legado.app.domain.usecase.CacheBookChaptersUseCase
+import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import kotlinx.collections.immutable.persistentListOf
-import splitties.init.appCtx
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Job
@@ -34,12 +35,14 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import splitties.init.appCtx
 
 class MangaReaderViewModel(
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val readSettingsGateway: ReadSettingsGateway,
     private val actionRepository: MangaReaderActionRepository,
+    private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
     sessionFactory: MangaReaderSessionFactory,
 ) : ViewModel() {
 
@@ -80,7 +83,7 @@ class MangaReaderViewModel(
         viewModelScope.launch {
             mangaSettingsGateway.settings.collect { settings ->
                 latestMangaSettings = settings
-                readerSession.execute(MangaSessionCommand.PrefetchCountChanged(settings.preDownloadNum))
+                readerSession.execute(MangaSessionCommand.PrefetchCountChanged(settings.chapterPrefetchCount))
                 _uiState.update { state ->
                     state.copy(settings = readSettings(settings))
                 }
@@ -216,6 +219,8 @@ class MangaReaderViewModel(
             is MangaReaderIntent.OpenSettings -> openSettings(intent.category)
             MangaReaderIntent.CloseSettings -> closeSettings()
             MangaReaderIntent.OpenSourceActions -> showSheet(MangaReaderSheet.SourceActions)
+            MangaReaderIntent.OpenCacheActions -> showSheet(MangaReaderSheet.CacheActions)
+            is MangaReaderIntent.CacheChapters -> cacheChapters(intent.selection)
             MangaReaderIntent.ToggleAutoRead -> _uiState.update {
                 it.copy(
                     autoReadEnabled = !it.autoReadEnabled,
@@ -234,6 +239,23 @@ class MangaReaderViewModel(
             is MangaReaderIntent.RetryChapter -> executeSession(
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
             )
+            is MangaReaderIntent.PageLoadStarted -> updatePageLoadState(
+                intent.key,
+                MangaPageLoadState.Loading,
+            )
+
+            is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
+                intent.key,
+                MangaPageLoadState.Ready,
+            )
+
+            is MangaReaderIntent.PageLoadFailed -> updatePageLoadState(
+                intent.key,
+                MangaPageLoadState.Failed(intent.message),
+            )
+
+            is MangaReaderIntent.RetryPage -> retryPage(intent.key)
+            is MangaReaderIntent.RetryFailedPagesInChapter -> retryFailedPages(intent.chapterIndex)
             is MangaReaderIntent.PageStep -> requestPageStep(intent.direction)
             is MangaReaderIntent.SeekToPage -> seekToPage(intent.pageIndex)
             is MangaReaderIntent.VisibleItemChanged -> updateVisibleItem(
@@ -252,9 +274,19 @@ class MangaReaderViewModel(
             }
             is MangaReaderIntent.LongPressPage -> {
                 if (_uiState.value.settings.longPressEnabled) {
-                    saveImage(intent.imageUrl)
+                    val fallback = findSpreadCompanion(intent.pageKey)
+                    showSheet(
+                        MangaReaderSheet.PageActions(
+                            intent.pageKey,
+                            intent.companionPageKey ?: fallback?.first,
+                            if (intent.companionPageKey != null) intent.companionBeforePage
+                            else fallback?.second == true,
+                        )
+                    )
                 }
             }
+
+            is MangaReaderIntent.ExecutePageAction -> executePageAction(intent.action)
             is MangaReaderIntent.MessageShown -> _uiState.update { state ->
                 state.copy(
                     pendingMessages = state.pendingMessages
@@ -348,14 +380,14 @@ class MangaReaderViewModel(
         }
         val current = session.currentChapter
         if (current !is MangaChapterState.Ready) {
-            if (current is MangaChapterState.Failed) showError(current.message)
-            else if (
-                pendingExplicitChapterIndex == session.chapterIndex &&
-                _uiState.value.pages.isNotEmpty()
-            ) {
-                // 显式跳章仍在加载时保留旧画面,避免先清空 Pager 导致闪屏。
-                _uiState.update { it.copy(isChapterLoading = true, errorMessage = null) }
-            } else showLoading()
+            if (pendingExplicitChapterIndex == session.chapterIndex) {
+                showExplicitChapterPlaceholder(
+                    chapterIndex = session.chapterIndex,
+                    pageIndex = session.pageIndex,
+                    errorMessage = (current as? MangaChapterState.Failed)?.message,
+                )
+            } else if (current is MangaChapterState.Failed) showError(current.message)
+            else showLoading()
             return
         }
         if (pagerScrollInProgress &&
@@ -367,6 +399,9 @@ class MangaReaderViewModel(
             return
         }
         deferredReadySession = null
+        val existingPages = _uiState.value.pages
+            .filterIsInstance<MangaReaderItemUi.Page>()
+            .associateBy(MangaReaderItemUi.Page::key)
         fun chapterItems(chapter: io.legado.app.domain.model.manga.MangaChapterContent) =
             if (chapter.isVolume && chapter.pages.isEmpty()) {
                 listOf(
@@ -377,8 +412,10 @@ class MangaReaderViewModel(
                 )
             } else {
                 chapter.pages.map { page ->
+                    val key = "page:${chapter.chapterIndex}:${page.pageIndex}:${page.imageUrl}"
+                    val existing = existingPages[key]
                     MangaReaderItemUi.Page(
-                        key = "page:${chapter.chapterIndex}:${page.pageIndex}:${page.imageUrl}",
+                        key = key,
                         imageUrl = page.imageUrl,
                         bookUrl = book.bookUrl,
                         chapterIndex = chapter.chapterIndex,
@@ -386,6 +423,8 @@ class MangaReaderViewModel(
                         pageIndex = page.pageIndex,
                         pageCount = page.pageCount,
                         chapterName = chapter.chapterTitle,
+                        loadState = existing?.loadState ?: MangaPageLoadState.Queued,
+                        retryRevision = existing?.retryRevision ?: 0,
                     )
                 }
             }
@@ -517,6 +556,7 @@ class MangaReaderViewModel(
                     pageCount = if (shouldPosition) current.chapter.pages.size else old.pageCount,
                     chapterIndex = session.chapterIndex,
                     chapterCount = session.chapterCount,
+                    cacheAvailable = !book.isLocal,
                     isLoading = false,
                     isChapterLoading = false,
                     pendingChapterIndex = null,
@@ -694,6 +734,17 @@ class MangaReaderViewModel(
             MangaReaderSettingKey.ZOOM_START_POSITION -> updateMangaPreference { it.copy(zoomStartPosition = value) }
             MangaReaderSettingKey.WIDE_PAGE_MODE -> updateMangaPreference { it.copy(widePageMode = value) }
             MangaReaderSettingKey.DOUBLE_PAGE_MODE -> updateMangaPreference { it.copy(doublePageMode = value) }
+            MangaReaderSettingKey.DOUBLE_PAGE_COVER_SINGLE -> updateMangaPreference {
+                it.copy(doublePageCoverSingle = value != 0)
+            }
+
+            MangaReaderSettingKey.DOUBLE_PAGE_INVERT -> updateMangaPreference {
+                it.copy(doublePageInvert = value != 0)
+            }
+
+            MangaReaderSettingKey.DOUBLE_PAGE_SHIFT -> updateMangaPreference {
+                it.copy(doublePageShift = value != 0)
+            }
             MangaReaderSettingKey.DISABLE_SCALE -> updateMangaPreference { it.copy(disableMangaScale = enabled) }
             MangaReaderSettingKey.DISABLE_SCROLL_ANIMATION -> updateMangaPreference {
                 it.copy(disableMangaScrollAnimation = enabled)
@@ -704,6 +755,9 @@ class MangaReaderViewModel(
             MangaReaderSettingKey.DISABLE_CLICK_SCROLL -> updateMangaPreference { it.copy(disableClickScroll = enabled) }
             MangaReaderSettingKey.LONG_PRESS -> updateMangaPreference { it.copy(longClick = enabled) }
             MangaReaderSettingKey.PRE_DOWNLOAD -> updateMangaPreference { it.copy(preDownloadNum = value) }
+            MangaReaderSettingKey.CHAPTER_PREFETCH -> updateMangaPreference {
+                it.copy(chapterPrefetchCount = value)
+            }
             MangaReaderSettingKey.AUTO_READ_SPEED -> updateMangaPreference { it.copy(autoPageSpeed = value) }
             MangaReaderSettingKey.VOLUME_KEY_PAGE -> updateMangaPreference { it.copy(volumeKeyPage = enabled) }
             MangaReaderSettingKey.REVERSE_VOLUME_KEY_PAGE -> updateMangaPreference {
@@ -829,14 +883,145 @@ class MangaReaderViewModel(
             pendingExplicitChapterIndex == chapterIndex
         ) return
         pendingExplicitChapterIndex = chapterIndex
+        showExplicitChapterPlaceholder(chapterIndex, pageIndex, errorMessage = null)
+        executeSession(MangaSessionCommand.OpenChapter(chapterIndex, pageIndex))
+    }
+
+    private fun cacheChapters(selection: MangaCacheSelection) {
+        val state = _uiState.value
+        if (!state.cacheAvailable || state.chapterCount <= 0) return
+        if (selection == MangaCacheSelection.FOLLOWING && state.chapterIndex >= state.chapterCount - 1) {
+            enqueueMessage(resId = R.string.manga_reader_no_next_chapter)
+            _uiState.update { it.copy(activeSheet = null) }
+            return
+        }
+        _uiState.update { it.copy(activeSheet = null) }
+        launchAction(successMessageRes = R.string.manga_reader_cache_enqueued) {
+            when (selection) {
+                MangaCacheSelection.CURRENT -> cacheBookChaptersUseCase.execute(
+                    state.bookUrl,
+                    listOf(state.chapterIndex),
+                )
+
+                MangaCacheSelection.FOLLOWING -> cacheBookChaptersUseCase.executeRange(
+                    state.bookUrl,
+                    state.chapterIndex + 1,
+                    state.chapterCount - 1,
+                )
+
+                MangaCacheSelection.ALL -> cacheBookChaptersUseCase.executeRange(
+                    state.bookUrl,
+                    0,
+                    state.chapterCount - 1,
+                )
+            }
+        }
+    }
+
+    private fun executePageAction(action: MangaPageAction) {
+        val sheet = _uiState.value.activeSheet as? MangaReaderSheet.PageActions ?: return
+        val selected = pageForKey(sheet.pageKey) ?: return
+        val companion = sheet.companionPageKey?.let(::pageForKey)
+        val pages = if (sheet.companionBeforePage) listOfNotNull(companion, selected)
+        else listOfNotNull(selected, companion)
+        val urls = when (action) {
+            MangaPageAction.SAVE_SPREAD, MangaPageAction.SHARE_SPREAD, MangaPageAction.COPY_SPREAD -> pages.map { it.imageUrl }
+            else -> listOf(selected.imageUrl)
+        }
+        _uiState.update { it.copy(activeSheet = null) }
+        launchAction(
+            successMessageRes = when (action) {
+                MangaPageAction.SAVE, MangaPageAction.SAVE_SPREAD -> R.string.manga_reader_image_saved
+                MangaPageAction.SET_COVER -> R.string.manga_reader_cover_set
+                else -> null
+            },
+            failureMessageRes = R.string.manga_reader_action_failed,
+        ) {
+            val book = requireNotNull(readerSession.state.value.book)
+            when (action) {
+                MangaPageAction.SAVE, MangaPageAction.SAVE_SPREAD -> check(
+                    actionRepository.saveImages(urls, book.bookUrl, book.sourceOrigin)
+                )
+
+                MangaPageAction.SHARE, MangaPageAction.SHARE_SPREAD -> _effects.tryEmit(
+                    MangaReaderEffect.ShareImage(
+                        actionRepository.prepareImageFile(
+                            urls,
+                            book.bookUrl,
+                            book.sourceOrigin
+                        ).path
+                    )
+                )
+
+                MangaPageAction.COPY, MangaPageAction.COPY_SPREAD -> _effects.tryEmit(
+                    MangaReaderEffect.CopyImage(
+                        actionRepository.prepareImageFile(
+                            urls,
+                            book.bookUrl,
+                            book.sourceOrigin
+                        ).path
+                    )
+                )
+
+                MangaPageAction.SET_COVER -> actionRepository.setBookCover(
+                    book.bookUrl,
+                    selected.imageUrl
+                )
+            }
+        }
+    }
+
+    private fun pageForKey(key: String) =
+        _uiState.value.pages.firstOrNull { it.key == key } as? MangaReaderItemUi.Page
+
+    private fun findSpreadCompanion(key: String): Pair<String, Boolean>? {
+        val state = _uiState.value
+        if (state.settings.doublePageMode == 0) return null
+        val pages = state.pages.filterIsInstance<MangaReaderItemUi.Page>()
+        val index = pages.indexOfFirst { it.key == key }
+        val selected = pages.getOrNull(index) ?: return null
+        pages.getOrNull(index + 1)?.takeIf { it.chapterIndex == selected.chapterIndex }
+            ?.let { return it.key to false }
+        return pages.getOrNull(index - 1)?.takeIf { it.chapterIndex == selected.chapterIndex }
+            ?.let { it.key to true }
+    }
+
+    private fun showExplicitChapterPlaceholder(
+        chapterIndex: Int,
+        pageIndex: Int,
+        errorMessage: String?,
+    ) {
+        val title = readerSession.state.value.book?.chapterTitles?.getOrNull(chapterIndex)
+            ?: appCtx.getString(R.string.manga_reader_transition_chapter_number, chapterIndex + 1)
+        val failed = errorMessage != null
+        val message = if (failed) {
+            errorMessage.takeUnless(String::isBlank)
+                ?: appCtx.getString(R.string.manga_reader_unknown_error)
+        } else {
+            appCtx.getString(R.string.manga_reader_loading_selected_chapter, title)
+        }
         _uiState.update { state ->
             state.copy(
-                isChapterLoading = state.pages.isNotEmpty(),
+                chapterName = title,
+                chapterIndex = chapterIndex,
+                currentPage = pageIndex.coerceAtLeast(0),
+                pageCount = 0,
+                pages = persistentListOf(
+                    mangaChapterLoadingItem(
+                        chapterIndex = chapterIndex,
+                        message = message,
+                        failed = failed,
+                    )
+                ),
+                currentItemIndex = 0,
+                isLoading = false,
+                isChapterLoading = !failed,
                 pendingChapterIndex = chapterIndex,
+                errorMessage = null,
+                scrollRequest = null,
                 navigationId = System.nanoTime(),
             )
         }
-        executeSession(MangaSessionCommand.OpenChapter(chapterIndex, pageIndex))
     }
 
     private fun openRelativeChapter(direction: Int) {
@@ -922,6 +1107,57 @@ class MangaReaderViewModel(
         }
     }
 
+    private fun updatePageLoadState(key: String, loadState: MangaPageLoadState) {
+        _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 == loadState) return@update state
+            state.copy(
+                pages = state.pages.mapIndexed { itemIndex, item ->
+                    if (itemIndex == index) page.copy(loadState = loadState) else item
+                }.toImmutableList(),
+            )
+        }
+    }
+
+    private fun retryPage(key: String) {
+        CoverFetcher.clearFailure(
+            (_uiState.value.pages.firstOrNull { it.key == key } as? MangaReaderItemUi.Page)
+                ?.imageUrl ?: return
+        )
+        _uiState.update { state ->
+            val index = state.pages.indexOfFirst { it.key == key }
+            val page =
+                state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
+            state.copy(
+                pages = state.pages.mapIndexed { itemIndex, item ->
+                    if (itemIndex == index) page.copy(
+                        loadState = MangaPageLoadState.Queued,
+                        retryRevision = page.retryRevision + 1,
+                    ) else item
+                }.toImmutableList(),
+            )
+        }
+    }
+
+    private fun retryFailedPages(chapterIndex: Int) {
+        _uiState.value.pages.filterIsInstance<MangaReaderItemUi.Page>()
+            .filter { it.chapterIndex == chapterIndex && it.loadState is MangaPageLoadState.Failed }
+            .forEach { CoverFetcher.clearFailure(it.imageUrl) }
+        _uiState.update { state ->
+            state.copy(pages = state.pages.map { item ->
+                if (item is MangaReaderItemUi.Page && item.chapterIndex == chapterIndex &&
+                    item.loadState is MangaPageLoadState.Failed
+                ) item.copy(
+                    loadState = MangaPageLoadState.Queued,
+                    retryRevision = item.retryRevision + 1
+                )
+                else item
+            }.toImmutableList())
+        }
+    }
+
     private fun readSettings(settings: MangaSettings): MangaReaderSettings {
         val colorFilter = GSON.fromJsonObject<MangaColorFilterConfig>(settings.colorFilter)
             .getOrNull() ?: MangaColorFilterConfig()
@@ -937,12 +1173,16 @@ class MangaReaderViewModel(
         zoomStartPosition = settings.zoomStartPosition,
         widePageMode = settings.widePageMode,
         doublePageMode = settings.doublePageMode,
+            doublePageCoverSingle = settings.doublePageCoverSingle,
+            doublePageInvert = settings.doublePageInvert,
+            doublePageShift = settings.doublePageShift,
         disableScale = settings.disableMangaScale,
         disableScrollAnimation = settings.disableMangaScrollAnimation,
         disableCrossFade = settings.disableMangaCrossFade,
         disableClickScroll = settings.disableClickScroll,
         longPressEnabled = settings.longClick,
         preDownloadCount = settings.preDownloadNum,
+            chapterPrefetchCount = settings.chapterPrefetchCount,
         autoReadSpeed = settings.autoPageSpeed,
         volumeKeyPage = settings.volumeKeyPage,
         reverseVolumeKeyPage = settings.reverseVolumeKeyPage,

+ 48 - 4
app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt

@@ -1,5 +1,7 @@
 package io.legado.app.ui.book.manga
 
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.tween
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.tween
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.MaterialTheme
@@ -34,11 +34,12 @@ import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
 import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.ui.book.manga.config.MangaDoublePageMode
 import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
@@ -396,6 +397,41 @@ private fun ReaderSettingsContent(
                 MangaReaderSettingKey.ZOOM_START_POSITION,
                 onIntent,
             )
+            if (settings.doublePageMode != MangaDoublePageMode.OFF) {
+                SettingSwitch(
+                    stringResource(R.string.manga_reader_double_page_cover_single),
+                    settings.doublePageCoverSingle,
+                ) {
+                    onIntent(
+                        MangaReaderIntent.UpdateSetting(
+                            MangaReaderSettingKey.DOUBLE_PAGE_COVER_SINGLE,
+                            it.intValue,
+                        )
+                    )
+                }
+                SettingSwitch(
+                    stringResource(R.string.manga_reader_double_page_invert),
+                    settings.doublePageInvert,
+                ) {
+                    onIntent(
+                        MangaReaderIntent.UpdateSetting(
+                            MangaReaderSettingKey.DOUBLE_PAGE_INVERT,
+                            it.intValue,
+                        )
+                    )
+                }
+                SettingSwitch(
+                    stringResource(R.string.manga_reader_double_page_shift),
+                    settings.doublePageShift,
+                ) {
+                    onIntent(
+                        MangaReaderIntent.UpdateSetting(
+                            MangaReaderSettingKey.DOUBLE_PAGE_SHIFT,
+                            it.intValue,
+                        )
+                    )
+                }
+            }
         }
         SettingDropdown(
             stringResource(R.string.manga_reader_wide_page),
@@ -404,6 +440,7 @@ private fun ReaderSettingsContent(
                 R.string.manga_reader_wide_normal,
                 R.string.manga_reader_wide_fit_width,
                 R.string.manga_reader_wide_rotate,
+                R.string.manga_reader_wide_split,
             ),
             MangaReaderSettingKey.WIDE_PAGE_MODE,
             onIntent,
@@ -425,10 +462,17 @@ private fun ReaderSettingsContent(
     SettingSlider(
         stringResource(R.string.manga_reader_preload_pages),
         settings.preDownloadCount,
-        0..30
+        0..10
     ) {
         onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.PRE_DOWNLOAD, it))
     }
+    SettingSlider(
+        stringResource(R.string.manga_reader_prefetch_chapters),
+        settings.chapterPrefetchCount,
+        0..3,
+    ) {
+        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.CHAPTER_PREFETCH, it))
+    }
     SettingSwitch(stringResource(R.string.manga_reader_pinch_zoom), !settings.disableScale) {
         onIntent(
             MangaReaderIntent.UpdateSetting(

+ 13 - 1
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.manga
 
+import android.content.ClipData
 import android.content.Intent
 import android.os.Bundle
 import android.view.KeyEvent
@@ -7,9 +8,11 @@ import android.view.WindowManager
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.core.content.FileProvider
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
+import io.legado.app.constant.AppConst
 import io.legado.app.receiver.NetworkChangedListener
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.READER_RESULT_DELETED
@@ -20,9 +23,11 @@ import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.openUrl
+import io.legado.app.utils.share
 import io.legado.app.utils.toggleSystemBar
-import org.koin.androidx.viewmodel.ext.android.viewModel
 import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.viewmodel.ext.android.viewModel
+import java.io.File
 
 /**
  * Android compatibility host for the Compose manga reader.
@@ -123,6 +128,13 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
                 else updateWindowBrightness(effect.brightness)
             }
             is MangaReaderEffect.SetSystemBarsVisible -> toggleSystemBar(effect.visible)
+            is MangaReaderEffect.ShareImage -> share(File(effect.filePath), "image/jpeg")
+            is MangaReaderEffect.CopyImage -> {
+                val uri =
+                    FileProvider.getUriForFile(this, AppConst.authority, File(effect.filePath))
+                getSystemService(android.content.ClipboardManager::class.java)
+                    .setPrimaryClip(ClipData.newUri(contentResolver, "manga", uri))
+            }
         }
     }
 

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/manga/config/MangaPageConfig.kt

@@ -20,6 +20,7 @@ object MangaWidePageMode {
     const val NORMAL = 0
     const val FIT_WIDTH = 1
     const val ROTATE_TO_FIT = 2
+    const val SPLIT = 3
 }
 
 object MangaDoublePageMode {

+ 7 - 8
app/src/main/java/io/legado/app/utils/ContextExtensions.kt

@@ -39,14 +39,13 @@ 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.config.AppConfigStore
-import io.legado.app.help.config.SettingsWriter
 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.config.AppConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
+import io.legado.app.help.config.AppConfigStore
+import io.legado.app.help.config.SettingsWriter
 import io.legado.app.ui.book.audio.AudioPlayActivity
+import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.runBlocking
@@ -70,13 +69,13 @@ fun Context.startActivityForBook(
 ) {
     val intent = when {
         book.isAudio -> Intent(this, AudioPlayActivity::class.java)
-        !book.isLocal && book.isImage && ReadMangaConfig.showMangaUi ->
+        book.isImage && ReadMangaConfig.showMangaUi ->
             MainActivity.createReadMangaIntent(this, book.bookUrl)
 
         else -> MainActivity.createReadBookIntent(this, book.bookUrl)
     }
     intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-    if (book.isAudio || (!book.isLocal && book.isImage && ReadMangaConfig.showMangaUi)) {
+    if (book.isAudio || (book.isImage && ReadMangaConfig.showMangaUi)) {
         intent.putExtra("bookUrl", book.bookUrl)
     }
     intent.apply(configIntent)
@@ -89,13 +88,13 @@ fun Context.startActivityForBook(
 ) {
     val intent = when {
         book.isAudio -> Intent(this, AudioPlayActivity::class.java)
-        !book.isLocal && book.isImage && ReadMangaConfig.showMangaUi ->
+        book.isImage && ReadMangaConfig.showMangaUi ->
             MainActivity.createReadMangaIntent(this, book.bookUrl)
 
         else -> MainActivity.createReadBookIntent(this, book.bookUrl)
     }
     intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-    if (book.isAudio || (!book.isLocal && book.isImage && ReadMangaConfig.showMangaUi)) {
+    if (book.isAudio || (book.isImage && ReadMangaConfig.showMangaUi)) {
         intent.putExtra("bookUrl", book.bookUrl)
     }
     intent.apply(configIntent)

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

@@ -3126,6 +3126,7 @@
     <string name="manga_reader_click_cycle_hint">点击每个区域可循环切换动作</string>
     <string name="manga_reader_loading_prev">上一章加载中…</string>
     <string name="manga_reader_loading_next">下一章加载中…</string>
+    <string name="manga_reader_loading_selected_chapter">正在加载“%1$s”…</string>
     <string name="manga_reader_no_previous_chapter">没有上一章</string>
     <string name="manga_reader_no_next_chapter">没有下一章</string>
     <string name="manga_reader_adjacent_failed">%1$s\n点击重试</string>
@@ -3172,8 +3173,24 @@
     <string name="manga_reader_wide_normal">普通适应</string>
     <string name="manga_reader_wide_fit_width">适应宽度</string>
     <string name="manga_reader_wide_rotate">旋转后适应</string>
+    <string name="manga_reader_wide_split">拆分为左右半页</string>
     <string name="manga_reader_double_page">双页模式</string>
+    <string name="manga_reader_double_page_cover_single">章节封面保持单页</string>
+    <string name="manga_reader_double_page_invert">反转双页顺序</string>
+    <string name="manga_reader_double_page_shift">双页错位一页</string>
     <string name="manga_reader_mode_off">关闭</string>
     <string name="manga_reader_mode_landscape">仅横屏</string>
     <string name="manga_reader_mode_always">始终</string>
+    <string name="manga_reader_set_cover">设为封面</string>
+    <string name="manga_reader_save_spread">保存双页</string>
+    <string name="manga_reader_share_spread">分享双页</string>
+    <string name="manga_reader_copy_spread">复制双页</string>
+    <string name="manga_reader_cover_set">封面已更新</string>
+    <string name="manga_reader_retry_failed_chapter">重试本章失败页面</string>
+    <string name="manga_reader_prefetch_chapters">预加载章节</string>
+    <string name="import_current_folder_as_manga">将当前目录作为漫画导入</string>
+    <string name="manga_reader_cache_current">缓存本章</string>
+    <string name="manga_reader_cache_following">缓存后续章节</string>
+    <string name="manga_reader_cache_all">缓存整部漫画</string>
+    <string name="manga_reader_cache_enqueued">已加入离线缓存队列</string>
 </resources>

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

@@ -3131,6 +3131,7 @@
     <string name="manga_reader_click_cycle_hint">Tap an area to cycle its action</string>
     <string name="manga_reader_loading_prev">Loading previous chapter…</string>
     <string name="manga_reader_loading_next">Loading next chapter…</string>
+    <string name="manga_reader_loading_selected_chapter">Loading “%1$s”…</string>
     <string name="manga_reader_no_previous_chapter">No previous chapter</string>
     <string name="manga_reader_no_next_chapter">No next chapter</string>
     <string name="manga_reader_adjacent_failed">%1$s\nTap to retry</string>
@@ -3177,8 +3178,24 @@
     <string name="manga_reader_wide_normal">Fit normally</string>
     <string name="manga_reader_wide_fit_width">Fit width</string>
     <string name="manga_reader_wide_rotate">Rotate to fit</string>
+    <string name="manga_reader_wide_split">Split into left and right pages</string>
     <string name="manga_reader_double_page">Dual page mode</string>
+    <string name="manga_reader_double_page_cover_single">Keep chapter cover single</string>
+    <string name="manga_reader_double_page_invert">Invert dual-page order</string>
+    <string name="manga_reader_double_page_shift">Shift pairing by one page</string>
     <string name="manga_reader_mode_off">Off</string>
     <string name="manga_reader_mode_landscape">Landscape only</string>
     <string name="manga_reader_mode_always">Always</string>
+    <string name="manga_reader_set_cover">Set as cover</string>
+    <string name="manga_reader_save_spread">Save spread</string>
+    <string name="manga_reader_share_spread">Share spread</string>
+    <string name="manga_reader_copy_spread">Copy spread</string>
+    <string name="manga_reader_cover_set">Cover updated</string>
+    <string name="manga_reader_retry_failed_chapter">Retry failed pages in chapter</string>
+    <string name="manga_reader_prefetch_chapters">Preload chapters</string>
+    <string name="import_current_folder_as_manga">Import current folder as comic</string>
+    <string name="manga_reader_cache_current">Cache chapter</string>
+    <string name="manga_reader_cache_following">Cache following</string>
+    <string name="manga_reader_cache_all">Cache entire comic</string>
+    <string name="manga_reader_cache_enqueued">Added to offline cache queue</string>
 </resources>

+ 9 - 1
app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt

@@ -1,8 +1,8 @@
 package io.legado.app.data.repository
 
 import androidx.datastore.preferences.core.Preferences
-import androidx.datastore.preferences.core.stringPreferencesKey
 import androidx.datastore.preferences.core.mutablePreferencesOf
+import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.model.settings.MangaSettings
@@ -176,6 +176,7 @@ private fun mangaMappingSamples(): List<MangaSettings> {
     val base = MangaSettings(
         scrollMode = 11,
         preDownloadNum = 22,
+        chapterPrefetchCount = 2,
         autoPageSpeed = 33,
         footerConfig = "manga-footer",
         background = 0xFF123456.toInt(),
@@ -215,6 +216,9 @@ private fun mangaMappingSamples(): List<MangaSettings> {
         base.copy(menuBottomBarFloating = false),
         base.copy(menuBottomBarBlur = true),
         base.copy(menuTopBarCompact = true),
+        base.copy(doublePageCoverSingle = false),
+        base.copy(doublePageInvert = true),
+        base.copy(doublePageShift = true),
     )
 }
 
@@ -225,6 +229,7 @@ private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.disableMangaCrossFade to disableMangaCrossFade,
     PreferKey.mangaScrollMode to scrollMode,
     PreferKey.mangaPreDownloadNum to preDownloadNum,
+    PreferKey.mangaChapterPrefetchCount to chapterPrefetchCount,
     PreferKey.mangaAutoPageSpeed to autoPageSpeed,
     PreferKey.mangaFooterConfig to footerConfig,
     PreferKey.disableClickScroll to disableClickScroll,
@@ -235,6 +240,9 @@ private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.mangaZoomStartPosition to zoomStartPosition,
     PreferKey.mangaWidePageMode to widePageMode,
     PreferKey.mangaDoublePageMode to doublePageMode,
+    PreferKey.mangaDoublePageCoverSingle to doublePageCoverSingle,
+    PreferKey.mangaDoublePageInvert to doublePageInvert,
+    PreferKey.mangaDoublePageShift to doublePageShift,
     PreferKey.mangaColorFilter to colorFilter,
     PreferKey.hideMangaTitle to hideTitle,
     PreferKey.enableMangaEInk to enableEInk,

+ 16 - 3
app/src/test/java/io/legado/app/data/repository/manga/DefaultMangaReaderSessionTest.kt

@@ -1,8 +1,8 @@
 package io.legado.app.data.repository.manga
 
 import io.legado.app.domain.gateway.MangaReaderDataGateway
-import io.legado.app.domain.model.manga.MangaBookState
 import io.legado.app.domain.model.manga.MangaBookPresentation
+import io.legado.app.domain.model.manga.MangaBookState
 import io.legado.app.domain.model.manga.MangaChapterContent
 import io.legado.app.domain.model.manga.MangaChapterState
 import io.legado.app.domain.model.manga.MangaPageContent
@@ -11,10 +11,10 @@ import io.legado.app.domain.model.manga.MangaSessionCommand
 import io.legado.app.domain.model.manga.OpenedMangaBook
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.advanceUntilIdle
 import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.flow.MutableStateFlow
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -82,6 +82,7 @@ class DefaultMangaReaderSessionTest {
 
         assertEquals(Triple("book-a", 0, 7), gateway.persisted.single())
         assertEquals(listOf("book-a"), gateway.paused)
+        assertTrue(gateway.resourcesReleased)
     }
 
     @Test
@@ -94,7 +95,7 @@ class DefaultMangaReaderSessionTest {
         session.execute(MangaSessionCommand.Open("book-a", true, false))
         advanceUntilIdle()
 
-        assertEquals(listOf("book-a" to 2, "book-a" to 3), gateway.prefetched)
+        assertEquals(listOf("book-a" to 1, "book-a" to 2), gateway.prefetched)
         session.close()
     }
 
@@ -143,6 +144,7 @@ class DefaultMangaReaderSessionTest {
         assertEquals(1, (state.currentChapter as MangaChapterState.Ready).chapter.chapterIndex)
         // 只加载新的远端章节(chapter 2)
         assertEquals("book-a" to 2, gateway.loadedRequests.lastOrNull())
+        assertEquals("book-a" to setOf(0, 1, 2), gateway.retainedWindows.last())
         session.close()
     }
 
@@ -200,6 +202,8 @@ class DefaultMangaReaderSessionTest {
         val paused = mutableListOf<String>()
         val prefetched = mutableListOf<Pair<String, Int>>()
         val loadedRequests = mutableListOf<Pair<String, Int>>()
+        val retainedWindows = mutableListOf<Pair<String, Set<Int>>>()
+        var resourcesReleased = false
 
         override fun observeBookPresentation(bookUrl: String) = presentation
 
@@ -220,6 +224,7 @@ class DefaultMangaReaderSessionTest {
                 inBookshelf = inBookshelf,
                 scrollMode = null,
                 sidePaddingDp = null,
+                isLocal = false,
                 chapterTitles = List(5) { "Chapter ${it + 1}" },
             ),
             chapterIndex = 0,
@@ -236,6 +241,14 @@ class DefaultMangaReaderSessionTest {
             prefetched += bookUrl to chapterIndex
         }
 
+        override suspend fun retainChapterResources(bookUrl: String, chapterIndexes: Set<Int>) {
+            retainedWindows += bookUrl to chapterIndexes
+        }
+
+        override suspend fun releaseAllChapterResources() {
+            resourcesReleased = true
+        }
+
         fun complete(bookUrl: String, chapterIndex: Int) {
             deferred(bookUrl, chapterIndex).complete(chapter(bookUrl, chapterIndex))
         }

+ 43 - 0
app/src/test/java/io/legado/app/data/repository/manga/LocalMangaLoaderTest.kt

@@ -0,0 +1,43 @@
+package io.legado.app.data.repository.manga
+
+import io.legado.app.constant.BookType
+import io.legado.app.data.entities.Book
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+import java.nio.file.Files
+
+class LocalMangaLoaderTest {
+
+    @Test
+    fun `directory subfolders become chapters and pages use natural order`() {
+        val root = Files.createTempDirectory("local-manga-test").toFile()
+        val cache = Files.createTempDirectory("local-manga-cache").toFile()
+        try {
+            val chapterOne = root.resolve("Chapter 1").apply { mkdirs() }
+            chapterOne.resolve("10.jpg").writeBytes(byteArrayOf(1))
+            chapterOne.resolve("2.jpg").writeBytes(byteArrayOf(2))
+            val chapterTwo = root.resolve("Chapter 2").apply { mkdirs() }
+            chapterTwo.resolve("1.png").writeBytes(byteArrayOf(3))
+            val book = Book(
+                type = BookType.local or BookType.image,
+                bookUrl = root.absolutePath,
+                name = "Comic",
+                originName = root.name,
+            )
+
+            LocalMangaLoader(cache).use { loader ->
+                val chapters = loader.chapters(book)
+                assertEquals(listOf("Chapter 1", "Chapter 2"), chapters.map { it.title })
+                val pages = loader.load(book, chapters.first()).pages
+                assertEquals(
+                    listOf("2.jpg", "10.jpg"),
+                    pages.map { it.imageUrl.substringAfterLast('/') })
+                assertFalse(loader.load(book, chapters.last()).pages.isEmpty())
+            }
+        } finally {
+            root.deleteRecursively()
+            cache.deleteRecursively()
+        }
+    }
+}

+ 57 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

@@ -12,6 +12,18 @@ import org.junit.Test
 
 class MangaReaderInteractionTest {
 
+    @Test
+    fun `explicit chapter placeholder stays at target and exposes retry after failure`() {
+        val loading = mangaChapterLoadingItem(12, "loading", failed = false)
+        val failed = mangaChapterLoadingItem(12, "failed", failed = true)
+
+        assertTrue(loading.loading)
+        assertEquals(null, loading.retryChapterIndex)
+        assertFalse(failed.loading)
+        assertEquals(12, failed.retryChapterIndex)
+        assertTrue(failed.key.contains("12"))
+    }
+
     private fun page(index: Int, chapter: Int = 0) = MangaReaderItemUi.Page(
         key = "p$index",
         imageUrl = "url$index",
@@ -285,6 +297,51 @@ class MangaReaderInteractionTest {
         assertTrue(paired.key.contains("p1"))
     }
 
+    @Test
+    fun `chapter cover can stay on a single spread`() {
+        val items = listOf(page(0), page(1), page(2), page(3))
+        assertEquals(
+            listOf(listOf(0), listOf(1, 2), listOf(3)),
+            buildMangaSpreads(items, doublePage = true, coverSingle = true)
+                .map { it.itemIndices },
+        )
+    }
+
+    @Test
+    fun `shift pairing leaves first page of every chapter single`() {
+        val items = listOf(page(0), page(1), page(2), page(0, 1), page(1, 1))
+        assertEquals(
+            listOf(listOf(0), listOf(1, 2), listOf(3), listOf(4)),
+            buildMangaSpreads(items, doublePage = true, shiftPairing = true)
+                .map { it.itemIndices },
+        )
+    }
+
+    @Test
+    fun `wide page split follows reading direction`() {
+        val items = listOf(page(0))
+        val ratios = mapOf("p0" to 2f)
+        val leftToRight = buildMangaSpreads(
+            items,
+            doublePage = true,
+            aspectRatios = ratios,
+            splitWidePages = true,
+        )
+        val rightToLeft = buildMangaSpreads(
+            items,
+            doublePage = true,
+            aspectRatios = ratios,
+            splitWidePages = true,
+            splitRightToLeft = true,
+        )
+        assertEquals(
+            listOf(MangaPageSlice.LEFT, MangaPageSlice.RIGHT),
+            leftToRight.map { it.slots.single().slice })
+        assertEquals(
+            listOf(MangaPageSlice.RIGHT, MangaPageSlice.LEFT),
+            rightToLeft.map { it.slots.single().slice })
+    }
+
     @Test
     fun `automatic zoom starts on reading direction side`() {
         val size = IntSize(100, 200)