Просмотр исходного кода

feat: 更完整的漫画实现

HapeLee 2 месяцев назад
Родитель
Сommit
1e797f61d6
52 измененных файлов с 4091 добавлено и 2028 удалено
  1. 5 1
      .claude/settings.local.json
  2. 2 0
      app/build.gradle.kts
  3. 13 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  4. 0 481
      app/src/main/java/io/legado/app/data/repository/MangaReaderSessionRepository.kt
  5. 26 0
      app/src/main/java/io/legado/app/data/repository/MangaSettingsRepository.kt
  6. 404 0
      app/src/main/java/io/legado/app/data/repository/manga/DefaultMangaReaderSession.kt
  7. 169 0
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt
  8. 265 0
      app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt
  9. 18 2
      app/src/main/java/io/legado/app/di/appModule.kt
  10. 35 0
      app/src/main/java/io/legado/app/domain/gateway/MangaReaderDataGateway.kt
  11. 14 0
      app/src/main/java/io/legado/app/domain/gateway/MangaReaderSession.kt
  12. 5 0
      app/src/main/java/io/legado/app/domain/gateway/MangaReaderSessionFactory.kt
  13. 146 0
      app/src/main/java/io/legado/app/domain/model/manga/MangaReaderSessionModels.kt
  14. 13 0
      app/src/main/java/io/legado/app/domain/model/settings/MangaSettings.kt
  15. 3 0
      app/src/main/java/io/legado/app/help/coil/CoverExtras.kt
  16. 14 2
      app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt
  17. 19 5
      app/src/main/java/io/legado/app/help/glide/LegadoDataUrlLoader.kt
  18. 2 1
      app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt
  19. 9 4
      app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt
  20. 4 3
      app/src/main/java/io/legado/app/help/http/HttpHelper.kt
  21. 1 4
      app/src/main/java/io/legado/app/help/source/SourceHelp.kt
  22. 0 754
      app/src/main/java/io/legado/app/model/ReadManga.kt
  23. 0 6
      app/src/main/java/io/legado/app/model/manga/BaseMangaPage.kt
  24. 0 9
      app/src/main/java/io/legado/app/model/manga/MangaChapter.kt
  25. 0 8
      app/src/main/java/io/legado/app/model/manga/MangaContent.kt
  26. 0 10
      app/src/main/java/io/legado/app/model/manga/MangaPage.kt
  27. 0 8
      app/src/main/java/io/legado/app/model/manga/ReaderLoading.kt
  28. 0 4
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  29. 49 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt
  30. 59 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt
  31. 21 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt
  32. 466 70
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt
  33. 12 1
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderRouteScreen.kt
  34. 808 178
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  35. 68 35
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt
  36. 468 252
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt
  37. 283 35
      app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt
  38. 1 0
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  39. 29 0
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaPageConfig.kt
  40. 52 3
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt
  41. 2 67
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt
  42. 3 1
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  43. 1 0
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt
  44. 14 7
      app/src/main/java/io/legado/app/ui/widget/components/pager/PagerHeight.kt
  45. 86 0
      app/src/main/java/io/legado/app/ui/widget/components/reader/ReaderMenuActionSquare.kt
  46. 0 73
      app/src/main/java/io/legado/app/ui/widget/components/reader/ReaderMenuPrimitives.kt
  47. 46 0
      app/src/main/res/values-zh-rCN/strings.xml
  48. 46 0
      app/src/main/res/values/strings.xml
  49. 22 2
      app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt
  50. 273 0
      app/src/test/java/io/legado/app/data/repository/manga/DefaultMangaReaderSessionTest.kt
  51. 112 1
      app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt
  52. 3 0
      gradle/libs.versions.toml

+ 5 - 1
.claude/settings.local.json

@@ -117,7 +117,11 @@
       "Bash(git revert *)",
       "Bash(xargs grep -l \"ImeLazyListAutoScroller\")",
       "Bash(xargs grep -l \"MarkdownBlock\")",
-      "Bash(./gradlew.bat assembleAppDebug)"
+      "Bash(./gradlew.bat assembleAppDebug)",
+      "Bash(./gradlew.bat test *)",
+      "Bash(./gradlew.bat :app:testAppDebugUnitTest --tests \"io.legado.app.data.repository.MangaSettingsMappingTest\" --tests \"io.legado.app.ui.book.manga.MangaReaderInteractionTest\")",
+      "Bash(./gradlew.bat :app:compileAppDebugKotlin -q)",
+      "Bash(./gradlew.bat :app:testDebugUnitTest --tests \"io.legado.app.data.repository.MangaSettingsMappingTest\" --tests \"io.legado.app.ui.book.manga.*\" -q)"
     ]
   }
 }

+ 2 - 0
app/build.gradle.kts

@@ -192,6 +192,7 @@ dependencies {
     coreLibraryDesugaring(libs.desugar)
     testImplementation(libs.junit)
     testImplementation(libs.robolectric)
+    testImplementation(libs.kotlinx.coroutines.test)
     androidTestImplementation(libs.bundles.androidTest)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlinx.collections.immutable)
@@ -274,6 +275,7 @@ dependencies {
     implementation(libs.coil.gif)
     implementation(libs.coil.svg)
     implementation(libs.coil.network.okhttp)
+    implementation(libs.telephoto.zoomable.image.coil3)
     implementation(libs.accompanist.webview)
     implementation(libs.kotlinx.coroutines.android)
     implementation(libs.androidx.lifecycle.viewmodel.compose)

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

@@ -290,6 +290,19 @@ object PreferKey {
     const val mangaClickActionBR = "mangaClickActionBottomRight"
     const val mangaLongClick = "mangaLongClick"
     const val mangaBackground = "mangaBackground"
+    const val mangaAutoBackground = "mangaAutoBackground"
+    const val mangaPageScaleType = "mangaPageScaleType"
+    const val mangaZoomStartPosition = "mangaZoomStartPosition"
+    const val mangaWidePageMode = "mangaWidePageMode"
+    const val mangaDoublePageMode = "mangaDoublePageMode"
+    const val mangaMenuTopBarLiquidGlass = "mangaMenuTopBarLiquidGlass"
+    const val mangaMenuBottomBarLiquidGlass = "mangaMenuBottomBarLiquidGlass"
+    const val mangaMenuBottomBarFloating = "mangaMenuBottomBarFloating"
+    const val mangaMenuBottomBarBlur = "mangaMenuBottomBarBlur"
+    const val mangaMenuTopBarCompact = "mangaMenuTopBarCompact"
+    const val mangaMenuColorSource = "mangaMenuColorSource"
+    const val mangaMenuSeedColor = "mangaMenuSeedColor"
+    const val mangaMenuPaletteStyle = "mangaMenuPaletteStyle"
     const val notificationsPost = "notificationsPost"
     const val ignoreBatteryPermission = "ignoreBatteryPermission"
     const val paddingDisplayCutouts = "paddingDisplayCutouts"

+ 0 - 481
app/src/main/java/io/legado/app/data/repository/MangaReaderSessionRepository.kt

@@ -1,481 +0,0 @@
-package io.legado.app.data.repository
-
-import android.app.Application
-import android.graphics.Bitmap
-import coil3.ImageLoader
-import coil3.request.ImageRequest
-import coil3.request.allowHardware
-import coil3.toBitmap
-import io.legado.app.R
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.BookType
-import io.legado.app.constant.EventBus
-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.data.entities.BookProgress
-import io.legado.app.domain.gateway.BackupSettingsGateway
-import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
-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.book.isLocal
-import io.legado.app.help.book.isLocalModified
-import io.legado.app.help.book.removeType
-import io.legado.app.help.book.simulatedTotalChapterNum
-import io.legado.app.help.source.getSourceType
-import io.legado.app.help.storage.Backup
-import io.legado.app.model.ReadManga
-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.manga.MangaContent
-import io.legado.app.model.webBook.WebBook
-import io.legado.app.utils.ImageSaveUtils
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.isTrue
-import io.legado.app.utils.mapParallelSafe
-import io.legado.app.utils.postEvent
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.onEmpty
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.withContext
-import java.io.ByteArrayOutputStream
-
-data class MangaReaderLaunchRequest(
-    val bookUrl: String?,
-    val inBookshelf: Boolean,
-    val chapterChanged: Boolean,
-)
-
-sealed interface MangaPaymentResult {
-    data class OpenUrl(
-        val url: String,
-        val sourceOrigin: String?,
-        val sourceName: String?,
-        val sourceType: Int?,
-    ) : MangaPaymentResult
-
-    data object Refreshed : MangaPaymentResult
-}
-
-sealed interface MangaReaderSessionEvent {
-    data class LoadFailed(val message: String) : MangaReaderSessionEvent
-    data class ConfirmProgress(val progress: BookProgress) : MangaReaderSessionEvent
-    data object Loading : MangaReaderSessionEvent
-    data object LoadStarted : MangaReaderSessionEvent
-    data class Message(val message: String) : MangaReaderSessionEvent
-}
-
-data class MangaReaderSessionState(
-    val content: MangaContent,
-    val bookName: String,
-    val bookAuthor: String,
-    val bookUrl: String,
-    val chapterName: String,
-    val chapterUrl: String?,
-    val sourceName: String,
-    val sourceUrl: String?,
-    val sourceType: Int?,
-    val chapterIndex: Int,
-    val chapterCount: Int,
-)
-
-/** Data/session boundary around the legacy ReadManga engine. */
-class MangaReaderSessionRepository(
-    private val application: Application,
-    private val database: AppDatabase,
-    private val getReadingProgressUseCase: io.legado.app.domain.usecase.GetReadingProgressUseCase,
-    private val imageLoader: ImageLoader,
-    private val backupSettingsGateway: BackupSettingsGateway,
-    private val downloadCacheSettingsGateway: DownloadCacheSettingsGateway,
-    private val otherSettingsGateway: OtherSettingsGateway,
-    private val readSettingsGateway: ReadSettingsGateway,
-) {
-    private val _events = MutableSharedFlow<MangaReaderSessionEvent>(extraBufferCapacity = 16)
-    val events = _events.asSharedFlow()
-    private val _sessionState = MutableStateFlow<MangaReaderSessionState?>(null)
-    val sessionState = _sessionState.asStateFlow()
-
-    private val engineCallback = object : ReadManga.Callback {
-        override fun upContent() {
-            publishSessionState()
-        }
-
-        override fun loadFail(msg: String) {
-            _events.tryEmit(MangaReaderSessionEvent.LoadFailed(msg))
-        }
-
-        override fun sureNewProgress(progress: BookProgress) {
-            _events.tryEmit(MangaReaderSessionEvent.ConfirmProgress(progress))
-        }
-
-        override fun showLoading() {
-            _events.tryEmit(MangaReaderSessionEvent.Loading)
-        }
-
-        override fun startLoad() {
-            _events.tryEmit(MangaReaderSessionEvent.LoadStarted)
-        }
-    }
-
-    fun openSession() = ReadManga.register(engineCallback)
-
-    fun closeSession() = ReadManga.unregister(engineCallback)
-
-    fun currentSessionState(): MangaReaderSessionState = MangaReaderSessionState(
-        content = ReadManga.mangaContents.copy(items = ReadManga.mangaContents.items.toList()),
-        bookName = ReadManga.book?.name.orEmpty(),
-        bookAuthor = ReadManga.book?.author.orEmpty(),
-        bookUrl = ReadManga.book?.bookUrl.orEmpty(),
-        chapterName = ReadManga.curMangaChapter?.chapter?.title
-            ?: ReadManga.book?.durChapterTitle.orEmpty(),
-        chapterUrl = ReadManga.curMangaChapter?.chapter?.url,
-        sourceName = ReadManga.bookSource?.bookSourceName.orEmpty(),
-        sourceUrl = ReadManga.bookSource?.bookSourceUrl,
-        sourceType = ReadManga.bookSource?.getSourceType(),
-        chapterIndex = ReadManga.durChapterIndex,
-        chapterCount = ReadManga.simulatedChapterSize,
-    )
-
-    private fun publishSessionState() {
-        _sessionState.value = currentSessionState()
-    }
-
-    fun currentBook(): Book? = ReadManga.book
-
-    fun currentSourceOrigin(): String? = ReadManga.book?.origin
-
-    fun currentChapterIndex(): Int = ReadManga.durChapterIndex
-
-    fun inBookshelf(): Boolean = ReadManga.inBookshelf
-
-    fun hasBook(): Boolean = ReadManga.book != null
-
-    fun moveToPreviousChapter(toFirst: Boolean = false) = ReadManga.moveToPrevChapter(toFirst)
-
-    fun moveToNextChapter(toFirst: Boolean = false) = ReadManga.moveToNextChapter(toFirst)
-
-    fun reloadContent() = ReadManga.loadContent()
-
-    fun reloadOrUpdateContent() = ReadManga.loadOrUpContent()
-
-    fun applyProgress(progress: BookProgress) = ReadManga.setProgress(progress)
-
-    fun setVisiblePage(chapterIndex: Int, pageIndex: Int) {
-        if (ReadManga.durChapterIndex == chapterIndex) {
-            ReadManga.durChapterPos = pageIndex
-            ReadManga.curPageChanged()
-        }
-    }
-
-    fun effectiveScrollMode(defaultValue: Int): Int =
-        ReadManga.book?.readConfig?.mangaScrollMode ?: defaultValue
-
-    fun effectiveSidePadding(defaultValue: Int): Int =
-        ReadManga.book?.readConfig?.webtoonSidePaddingDp ?: defaultValue
-
-    suspend fun initialize(request: MangaReaderLaunchRequest): BookProgress? = withContext(Dispatchers.IO) {
-        ReadManga.inBookshelf = request.inBookshelf
-        ReadManga.chapterChanged = request.chapterChanged
-        val book = request.bookUrl?.takeIf(String::isNotEmpty)?.let(database.bookDao::getBook)
-            ?: database.bookDao.lastReadBook
-            ?: ReadManga.book
-            ?: throw NoStackTraceException(application.getString(R.string.no_book))
-        val progress = initializeBook(book)
-        ReadManga.saveRead()
-        progress
-    }
-
-    private suspend fun initializeBook(book: Book): BookProgress? {
-        val sameBook = ReadManga.book?.bookUrl == book.bookUrl
-        if (sameBook) ReadManga.upData(book) else ReadManga.resetData(book)
-
-        if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
-            throw NoStackTraceException(application.getString(R.string.manga_reader_details_failed))
-        }
-        if (book.isLocal && !localBookExists(book)) {
-            throw NoStackTraceException(application.getString(R.string.no_book))
-        }
-        if ((ReadManga.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
-            throw NoStackTraceException(application.getString(R.string.error_load_toc))
-        }
-
-        if (sameBook) ReadManga.loadOrUpContent() else ReadManga.loadContent()
-
-        val progress = when {
-            ReadManga.chapterChanged -> {
-                ReadManga.chapterChanged = false
-                null
-            }
-            !sameBook && backupSettingsGateway.currentSettings.syncBookProgressPlus -> {
-                ReadManga.syncProgress(
-                    newProgressAction = { progress ->
-                        ReadManga.mCallback?.sureNewProgress(progress)
-                    }
-                )
-                null
-            }
-            !sameBook -> syncBookProgress(book)
-            else -> null
-        }
-
-        if (!book.isLocal && ReadManga.bookSource == null &&
-            readSettingsGateway.currentSettings.autoChangeSource
-        ) {
-            autoChangeSource(book.name, book.author)
-        }
-        return progress
-    }
-
-    suspend fun refreshBookSource() = withContext(Dispatchers.IO) {
-        ReadManga.book?.let { book ->
-            ReadManga.bookSource = database.bookSourceDao.getBookSource(book.origin)
-        }
-    }
-
-    suspend fun disableSource() = withContext(Dispatchers.IO) {
-        ReadManga.bookSource?.let { source ->
-            source.enabled = false
-            database.bookSourceDao.update(source)
-        }
-    }
-
-    suspend fun loadChapterList(book: Book): Boolean = withContext(Dispatchers.IO) {
-        loadChapterListAwait(book)
-    }
-
-    private suspend fun loadChapterListAwait(book: Book): Boolean {
-        val source = ReadManga.bookSource ?: return true
-        val oldBook = book.copy()
-        return WebBook.getChapterListAwait(source, book, true).fold(
-            onSuccess = { chapters ->
-                if (oldBook.bookUrl == book.bookUrl) database.bookDao.update(book)
-                else {
-                    database.bookDao.replace(oldBook, book)
-                    BookHelp.updateCacheFolder(oldBook, book)
-                }
-                database.bookChapterDao.delByBook(oldBook.bookUrl)
-                database.bookChapterDao.insert(*chapters.toTypedArray())
-                ReadManga.onChapterListUpdated(book)
-                true
-            },
-            onFailure = {
-                ReadManga.mCallback?.loadFail(application.getString(R.string.error_load_toc))
-                false
-            },
-        )
-    }
-
-    private suspend fun loadBookInfo(book: Book): Boolean {
-        val source = ReadManga.bookSource ?: return true
-        return runCatching {
-            WebBook.getBookInfoAwait(source, book, canReName = false)
-        }.fold(
-            onSuccess = { true },
-            onFailure = {
-                ReadManga.mCallback?.loadFail(
-                    application.getString(R.string.manga_reader_details_error, it.localizedMessage)
-                )
-                false
-            },
-        )
-    }
-
-    private suspend fun autoChangeSource(name: String, author: String) {
-        val sources = database.bookSourceDao.allTextEnabledPart
-        flow {
-            sources.forEach { source -> source.getBookSource()?.let { emit(it) } }
-        }.mapParallelSafe(
-            downloadCacheSettingsGateway.currentSettings.threadCount
-        ) { source ->
-            val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
-            if (book.tocUrl.isEmpty()) WebBook.getBookInfoAwait(source, book)
-            val toc = WebBook.getChapterListAwait(source, book).getOrThrow()
-            val chapter = toc.getOrElse(book.durChapterIndex) { toc.last() }
-            val nextChapter = toc.getOrElse(chapter.index) { toc.first() }
-            WebBook.getContentAwait(source, book, chapter, nextChapter.url)
-            book to toc
-        }.take(1).onEmpty {
-            throw NoStackTraceException(application.getString(R.string.manga_reader_no_source))
-        }.catch {
-            AppLog.put("Automatic source change failed\n${it.localizedMessage}", it)
-            _events.tryEmit(
-                MangaReaderSessionEvent.Message(
-                    it.localizedMessage ?: application.getString(R.string.manga_reader_auto_source_failed)
-                )
-            )
-        }.collect { (book, toc) -> changeSource(book, toc) }
-    }
-
-    private suspend fun syncBookProgress(book: Book): BookProgress? {
-        if (!backupSettingsGateway.currentSettings.syncBookProgress) return null
-        val progress = getReadingProgressUseCase.execute(book.name, book.author)?.let {
-            BookProgress(
-                name = it.name,
-                author = it.author,
-                durChapterIndex = it.durChapterIndex,
-                durChapterPos = it.durChapterPos,
-                durChapterTime = it.durChapterTime,
-                durChapterTitle = it.durChapterTitle,
-            )
-        } ?: return null
-        return if (progress.durChapterIndex < book.durChapterIndex ||
-            progress.durChapterIndex == book.durChapterIndex && progress.durChapterPos < book.durChapterPos
-        ) {
-            progress
-        } else {
-            if (progress.durChapterIndex < book.simulatedTotalChapterNum()) ReadManga.setProgress(progress)
-            null
-        }
-    }
-
-    suspend fun changeSource(book: Book, toc: List<BookChapter>) = withContext(Dispatchers.IO) {
-        ReadManga.book?.migrateTo(
-            newBook = book,
-            toc = toc,
-            defaultReplaceEnabled = otherSettingsGateway.currentSettings.replaceEnableDefault,
-            chineseConverterType = readSettingsGateway.currentSettings.chineseConverterType,
-        )
-        book.removeType(BookType.updateError)
-        ReadManga.book?.delete()
-        database.bookDao.insert(book)
-        database.bookChapterDao.insert(*toc.toTypedArray())
-        ReadManga.resetData(book)
-        ReadManga.loadContent()
-        postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
-    }
-
-    suspend fun openChapter(index: Int, pageIndex: Int = 0) = withContext(Dispatchers.IO) {
-        val currentBook = ReadManga.book ?: return@withContext
-        database.bookDao.getBook(currentBook.bookUrl)?.let(ReadManga::upData)
-        if (index !in 0 until ReadManga.simulatedChapterSize) return@withContext
-        ReadManga.durChapterIndex = index
-        ReadManga.durChapterPos = pageIndex.coerceAtLeast(0)
-        ReadManga.clearMangaChapter()
-        ReadManga.showLoading()
-        ReadManga.loadContent()
-        ReadManga.saveRead(pageChanged = true)
-    }
-
-    suspend fun removeCurrentTemporaryBook() = withContext(Dispatchers.IO) {
-        val book = ReadManga.book
-        val url = book?.bookUrl
-        book?.delete()
-        if (ReadManga.book?.bookUrl == url) ReadManga.book = null
-    }
-
-    suspend fun addCurrentBookToShelf() = withContext(Dispatchers.IO) {
-        val book = ReadManga.book ?: throw NoStackTraceException(application.getString(R.string.no_book))
-        persistOnShelf(book, database.bookChapterDao.getChapterList(book.bookUrl))
-        ReadManga.inBookshelf = true
-    }
-
-    suspend fun addToShelf(book: Book, toc: List<BookChapter>) = withContext(Dispatchers.IO) {
-        persistOnShelf(book, toc)
-    }
-
-    private suspend fun persistOnShelf(book: Book, toc: List<BookChapter>) {
-        book.removeType(BookType.notShelf)
-        if (book.order == 0) book.order = database.bookDao.minOrder - 1
-        database.bookDao.insert(book)
-        database.bookChapterDao.insert(*toc.toTypedArray())
-    }
-
-    suspend fun updateReadConfig(update: ReadConfig.() -> Unit) = withContext(Dispatchers.IO) {
-        val book = ReadManga.book ?: return@withContext
-        book.readConfig = (book.readConfig ?: ReadConfig()).apply(update)
-        database.bookDao.update(book)
-    }
-
-    suspend fun refreshCurrentChapter() = withContext(Dispatchers.IO) {
-        val book = ReadManga.book ?: return@withContext
-        database.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)?.let { chapter ->
-            BookHelp.delContent(book, chapter)
-            openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos)
-        }
-    }
-
-    suspend fun saveImage(url: String, folderName: String = "Legado"): Boolean =
-        withContext(Dispatchers.IO) {
-            val result = imageLoader.execute(
-                ImageRequest.Builder(application).data(url).allowHardware(false).build()
-            )
-            val bitmap = requireNotNull(result.image).toBitmap()
-            val bytes = ByteArrayOutputStream().use { output ->
-                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
-                output.toByteArray()
-            }
-            ImageSaveUtils.saveImageToGallery(application, bytes, folderName = folderName)
-        }
-
-    suspend fun payCurrentChapter(): MangaPaymentResult = withContext(Dispatchers.IO) {
-        val book = ReadManga.book ?: throw NoStackTraceException(application.getString(R.string.no_book))
-        if (book.isLocal) throw NoStackTraceException("local book")
-        val chapter = database.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)
-            ?: throw NoStackTraceException("no chapter")
-        val source = ReadManga.bookSource ?: throw NoStackTraceException("no book source")
-        val payAction = source.getContentRule().payAction
-        if (payAction.isNullOrBlank()) throw NoStackTraceException("no pay action")
-        val analyzeRule = AnalyzeRule(book, source).apply {
-            setCoroutineContext(currentCoroutineContext())
-            setBaseUrl(chapter.url)
-            setChapter(chapter)
-        }
-        val result = analyzeRule.evalJS(payAction).toString()
-        when {
-            result.isAbsUrl() -> MangaPaymentResult.OpenUrl(
-                result,
-                source.bookSourceUrl,
-                source.bookSourceName,
-                source.getSourceType(),
-            )
-            result.isTrue() -> {
-                ReadManga.curMangaChapter = null
-                BookHelp.delContent(book, chapter)
-                loadChapterListAwait(book)
-                MangaPaymentResult.Refreshed
-            }
-            else -> throw NoStackTraceException("pay action returned $result")
-        }
-    }
-
-    fun resumeSession() {
-        ReadManga.initReadTime()
-        ReadManga.startAutoSaveSession()
-    }
-
-    fun syncProgressOnNetworkAvailable() {
-        if (backupSettingsGateway.currentSettings.syncBookProgressPlus) ReadManga.syncProgress()
-    }
-
-    fun pauseSession() {
-        val shouldPersist = ReadManga.inBookshelf
-        if (shouldPersist) {
-            ReadManga.saveRead()
-        }
-        ReadManga.stopAutoSaveSession()
-        ReadManga.commitReadSession()
-        ReadManga.cancelPreDownloadTask()
-        if (shouldPersist && !io.legado.app.BuildConfig.DEBUG) {
-            if (backupSettingsGateway.currentSettings.syncBookProgressPlus) ReadManga.syncProgress()
-            else ReadManga.uploadProgress()
-            Backup.autoBack(application)
-        }
-    }
-
-    private fun localBookExists(book: Book): Boolean = runCatching {
-        LocalBook.getBookInputStream(book)
-    }.isSuccess
-
-}

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

@@ -42,6 +42,11 @@ internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.disableClickScroll to disableClickScroll,
     PreferKey.mangaLongClick to longClick,
     PreferKey.mangaBackground to background,
+    PreferKey.mangaAutoBackground to autoBackground,
+    PreferKey.mangaPageScaleType to pageScaleType,
+    PreferKey.mangaZoomStartPosition to zoomStartPosition,
+    PreferKey.mangaWidePageMode to widePageMode,
+    PreferKey.mangaDoublePageMode to doublePageMode,
     PreferKey.mangaColorFilter to colorFilter,
     PreferKey.hideMangaTitle to hideTitle,
     PreferKey.enableMangaEInk to enableEInk,
@@ -50,6 +55,14 @@ internal fun MangaSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.webtoonSidePaddingDp to webtoonSidePaddingDp,
     PreferKey.mangaVolumeKeyPage to volumeKeyPage,
     PreferKey.reverseVolumeKeyPage to reverseVolumeKeyPage,
+    PreferKey.mangaMenuTopBarLiquidGlass to menuTopBarLiquidGlass,
+    PreferKey.mangaMenuBottomBarLiquidGlass to menuBottomBarLiquidGlass,
+    PreferKey.mangaMenuBottomBarFloating to menuBottomBarFloating,
+    PreferKey.mangaMenuBottomBarBlur to menuBottomBarBlur,
+    PreferKey.mangaMenuTopBarCompact to menuTopBarCompact,
+    PreferKey.mangaMenuColorSource to menuColorSource,
+    PreferKey.mangaMenuSeedColor to menuSeedColor,
+    PreferKey.mangaMenuPaletteStyle to menuPaletteStyle,
     PreferKey.mangaClickActionTL to clickActionTL,
     PreferKey.mangaClickActionTC to clickActionTC,
     PreferKey.mangaClickActionTR to clickActionTR,
@@ -73,6 +86,11 @@ internal fun Preferences.toMangaSettings(): MangaSettings = MangaSettings(
     disableClickScroll = compatDsBoolean(PreferKey.disableClickScroll) ?: false,
     longClick = compatDsBoolean(PreferKey.mangaLongClick) ?: true,
     background = compatDsInt(PreferKey.mangaBackground) ?: 0xFF000000.toInt(),
+    autoBackground = compatDsBoolean(PreferKey.mangaAutoBackground) ?: false,
+    pageScaleType = compatDsInt(PreferKey.mangaPageScaleType) ?: 0,
+    zoomStartPosition = compatDsInt(PreferKey.mangaZoomStartPosition) ?: 0,
+    widePageMode = compatDsInt(PreferKey.mangaWidePageMode) ?: 0,
+    doublePageMode = compatDsInt(PreferKey.mangaDoublePageMode) ?: 0,
     colorFilter = compatDsString(PreferKey.mangaColorFilter).orEmpty(),
     hideTitle = compatDsBoolean(PreferKey.hideMangaTitle) ?: false,
     enableEInk = compatDsBoolean(PreferKey.enableMangaEInk) ?: false,
@@ -81,6 +99,14 @@ internal fun Preferences.toMangaSettings(): MangaSettings = MangaSettings(
     webtoonSidePaddingDp = compatDsInt(PreferKey.webtoonSidePaddingDp) ?: 0,
     volumeKeyPage = compatDsBoolean(PreferKey.mangaVolumeKeyPage) ?: false,
     reverseVolumeKeyPage = compatDsBoolean(PreferKey.reverseVolumeKeyPage) ?: false,
+    menuTopBarLiquidGlass = compatDsBoolean(PreferKey.mangaMenuTopBarLiquidGlass) ?: false,
+    menuBottomBarLiquidGlass = compatDsBoolean(PreferKey.mangaMenuBottomBarLiquidGlass) ?: false,
+    menuBottomBarFloating = compatDsBoolean(PreferKey.mangaMenuBottomBarFloating) ?: true,
+    menuBottomBarBlur = compatDsBoolean(PreferKey.mangaMenuBottomBarBlur) ?: false,
+    menuTopBarCompact = compatDsBoolean(PreferKey.mangaMenuTopBarCompact) ?: false,
+    menuColorSource = compatDsInt(PreferKey.mangaMenuColorSource) ?: 0,
+    menuSeedColor = compatDsInt(PreferKey.mangaMenuSeedColor) ?: 0xFF6750A4.toInt(),
+    menuPaletteStyle = compatDsString(PreferKey.mangaMenuPaletteStyle) ?: "tonalSpot",
     clickActionTL = compatDsInt(PreferKey.mangaClickActionTL) ?: -1,
     clickActionTC = compatDsInt(PreferKey.mangaClickActionTC) ?: -1,
     clickActionTR = compatDsInt(PreferKey.mangaClickActionTR) ?: 1,

+ 404 - 0
app/src/main/java/io/legado/app/data/repository/manga/DefaultMangaReaderSession.kt

@@ -0,0 +1,404 @@
+package io.legado.app.data.repository.manga
+
+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
+import io.legado.app.domain.model.manga.MangaSessionState
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+class DefaultMangaReaderSession(
+    private val dataGateway: MangaReaderDataGateway,
+    private val stateDispatcher: CoroutineDispatcher,
+    private val ioDispatcher: CoroutineDispatcher,
+) : MangaReaderSession {
+
+    private companion object {
+        const val ADJACENT_LOAD_DISTANCE = 5
+        const val PROGRESS_PERSIST_INTERVAL_MS = 3_000L
+    }
+
+    private val sessionJob = SupervisorJob()
+    private val sessionScope = CoroutineScope(sessionJob + stateDispatcher)
+    private var loadJob = SupervisorJob(sessionJob)
+    private var loadScope = CoroutineScope(loadJob + ioDispatcher)
+    private var presentationJob: Job? = null
+    private var scheduledProgressPersistJob: Job? = null
+    private var lastProgressPersistAt = 0L
+    private val commands = Channel<MangaSessionCommand>(Channel.UNLIMITED)
+    private val _state = MutableStateFlow(MangaSessionState(MangaSessionId.create()))
+    override val state = _state.asStateFlow()
+    private val _events = MutableSharedFlow<MangaSessionEvent>(extraBufferCapacity = 16)
+    override val events = _events.asSharedFlow()
+    private var prefetchCount = 0
+
+    init {
+        sessionScope.launch {
+            for (command in commands) {
+                try {
+                    reduce(command)
+                } catch (error: CancellationException) {
+                    throw error
+                } catch (error: Throwable) {
+                    // 单条命令失败不能终止命令循环,否则后续命令永久排队、阅读器失去响应
+                    _events.tryEmit(
+                        MangaSessionEvent.Message(
+                            error.localizedMessage
+                                ?: appCtx.getString(R.string.manga_reader_action_failed)
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    override suspend fun execute(command: MangaSessionCommand) {
+        commands.send(command)
+    }
+
+    private suspend fun reduce(command: MangaSessionCommand) {
+        when (command) {
+            is MangaSessionCommand.Open -> open(command)
+            is MangaSessionCommand.OpenChapter -> moveTo(
+                command.chapterIndex,
+                command.pageIndex,
+                persist = true,
+            )
+            MangaSessionCommand.PreviousChapter -> moveTo(
+                _state.value.chapterIndex - 1,
+                0,
+                persist = true,
+            )
+            MangaSessionCommand.NextChapter -> moveTo(
+                _state.value.chapterIndex + 1,
+                0,
+                persist = true,
+            )
+            is MangaSessionCommand.PromoteVisibleChapter -> promoteVisibleChapter(command)
+            is MangaSessionCommand.VisiblePageChanged -> visiblePageChanged(command)
+            MangaSessionCommand.PersistVisibleProgress -> {
+                scheduledProgressPersistJob = null
+                persistProgress()
+            }
+            is MangaSessionCommand.RetryChapter -> retryChapter(command.chapterIndex)
+            is MangaSessionCommand.PrefetchCountChanged -> {
+                prefetchCount = command.count.coerceAtLeast(0)
+                prefetch(_state.value.chapterIndex)
+            }
+            is MangaSessionCommand.BookPresentationObserved -> {
+                val state = _state.value
+                val book = state.book ?: return
+                if (book.bookUrl != command.bookUrl) return
+                _state.value = state.copy(
+                    book = book.copy(
+                        scrollMode = command.presentation.scrollMode,
+                        sidePaddingDp = command.presentation.sidePaddingDp,
+                    )
+                )
+            }
+            is MangaSessionCommand.ApplyProgress -> applyProgress(command)
+            is MangaSessionCommand.ChapterLoaded -> chapterLoaded(command)
+            MangaSessionCommand.Resume -> {
+                _state.value.book?.let { withContext(ioDispatcher) { dataGateway.resume(it.bookUrl) } }
+                _state.value = _state.value.copy(resumed = true)
+            }
+            MangaSessionCommand.Pause -> {
+                cancelScheduledProgressPersist()
+                persistProgress()
+                _state.value.book?.let {
+                    withContext(ioDispatcher) { dataGateway.pause(it.bookUrl, it.inBookshelf) }
+                }
+                _state.value = _state.value.copy(resumed = false)
+            }
+            MangaSessionCommand.NetworkAvailable -> syncProgress()
+            MangaSessionCommand.Close -> shutdown()
+        }
+    }
+
+    private suspend fun open(command: MangaSessionCommand.Open) {
+        resetLoadScope()
+        presentationJob?.cancel()
+        cancelScheduledProgressPersist()
+        val old = _state.value
+        if (old.book != null) {
+            persistProgress()
+            if (old.resumed) withContext(ioDispatcher) {
+                dataGateway.pause(old.book.bookUrl, old.book.inBookshelf)
+            }
+        }
+        _state.value = MangaSessionState(
+            sessionId = old.sessionId,
+            revision = old.revision + 1,
+            resumed = old.resumed,
+        )
+        runCatching {
+            withContext(ioDispatcher) {
+                dataGateway.openBook(command.bookUrl, command.inBookshelf, command.chapterChanged)
+            }
+        }.onSuccess { opened ->
+            _state.value = _state.value.copy(
+                book = opened.book,
+                chapterIndex = opened.chapterIndex,
+                pageIndex = opened.pageIndex,
+                chapterCount = opened.chapterCount,
+            )
+            presentationJob = sessionScope.launch {
+                dataGateway.observeBookPresentation(opened.book.bookUrl).collect { presentation ->
+                    commands.send(
+                        MangaSessionCommand.BookPresentationObserved(
+                            opened.book.bookUrl,
+                            presentation,
+                        )
+                    )
+                }
+            }
+            opened.newerProgress?.let {
+                _events.tryEmit(MangaSessionEvent.ConfirmProgress(it))
+            }
+            if (_state.value.resumed) {
+                withContext(ioDispatcher) { dataGateway.resume(opened.book.bookUrl) }
+            }
+            loadWindow(opened.chapterIndex)
+        }.onFailure { error ->
+            val message = error.localizedMessage.orEmpty()
+            _state.value = _state.value.copy(openError = message)
+            _events.tryEmit(MangaSessionEvent.Message(message))
+        }
+    }
+
+    private suspend fun moveTo(chapterIndex: Int, pageIndex: Int, persist: Boolean) {
+        val state = _state.value
+        if (chapterIndex !in 0 until state.chapterCount) return
+        resetLoadScope()
+        cancelScheduledProgressPersist()
+        _state.value = state.copy(
+            revision = state.revision + 1,
+            chapterIndex = chapterIndex,
+            pageIndex = pageIndex.coerceAtLeast(0),
+            previousChapter = MangaChapterState.Empty,
+            currentChapter = MangaChapterState.Empty,
+            nextChapter = MangaChapterState.Empty,
+        )
+        if (persist) persistProgress()
+        loadWindow(chapterIndex)
+    }
+
+    private fun loadWindow(chapterIndex: Int) {
+        loadChapter(chapterIndex)
+        prefetch(chapterIndex)
+    }
+
+    /**
+     * Makes an already visible adjacent chapter current without replacing the reader window.
+     * The UI has already crossed its transition page, so resetting the list here would cause a
+     * jump back to the chapter start.
+     */
+    private fun promoteVisibleChapter(command: MangaSessionCommand.PromoteVisibleChapter) {
+        val state = _state.value
+        if (command.chapterIndex !in 0 until state.chapterCount ||
+            command.chapterIndex == state.chapterIndex
+        ) return
+        val movingForward = command.chapterIndex > state.chapterIndex
+        val target = if (movingForward) state.nextChapter else state.previousChapter
+        if (target !is MangaChapterState.Ready) return
+        _state.value = state.copy(
+            revision = state.revision + 1,
+            chapterIndex = command.chapterIndex,
+            pageIndex = command.pageIndex.coerceAtLeast(0),
+            previousChapter = if (movingForward) state.currentChapter else MangaChapterState.Empty,
+            currentChapter = target,
+            nextChapter = if (movingForward) MangaChapterState.Empty else state.currentChapter,
+        )
+        if (movingForward) loadChapter(command.chapterIndex + 1)
+        else loadChapter(command.chapterIndex - 1)
+        prefetch(command.chapterIndex)
+    }
+
+    private fun prefetch(chapterIndex: Int) {
+        val state = _state.value
+        val book = state.book ?: return
+        val indexes = (chapterIndex + 2..chapterIndex + 1 + prefetchCount)
+            .filter { it in 0 until state.chapterCount }
+        indexes.forEach { index ->
+            val token = state.tokenFor(index) ?: return@forEach
+            loadScope.launch {
+                if (_state.value.accepts(token)) {
+                    runCatching { dataGateway.prefetchChapter(book.bookUrl, index) }
+                }
+            }
+        }
+    }
+
+    private fun loadChapter(chapterIndex: Int) {
+        val state = _state.value
+        if (chapterIndex !in 0 until state.chapterCount) return
+        when (state.chapterStateAt(chapterIndex)) {
+            is MangaChapterState.Loading,
+            is MangaChapterState.Ready -> return
+            MangaChapterState.Empty,
+            is MangaChapterState.Failed -> Unit
+            null -> return
+        }
+        val token = state.tokenFor(chapterIndex) ?: return
+        setChapterState(chapterIndex, MangaChapterState.Loading(token))
+        loadScope.launch {
+            val result = runCatching {
+                dataGateway.loadChapter(token.bookUrl, token.chapterIndex)
+            }
+            commands.send(MangaSessionCommand.ChapterLoaded(token, result))
+        }
+    }
+
+    private fun chapterLoaded(command: MangaSessionCommand.ChapterLoaded) {
+        if (!_state.value.accepts(command.token)) return
+        val chapterState = command.result.fold(
+            onSuccess = { MangaChapterState.Ready(command.token, it) },
+            onFailure = {
+                MangaChapterState.Failed(
+                    token = command.token,
+                    message = it.localizedMessage.orEmpty(),
+                )
+            },
+        )
+        setChapterState(command.token.chapterIndex, chapterState)
+        if (chapterState is MangaChapterState.Ready &&
+            command.token.chapterIndex == _state.value.chapterIndex
+        ) {
+            val lastPageIndex = chapterState.chapter.pages.lastIndex.coerceAtLeast(0)
+            _state.value = _state.value.copy(
+                pageIndex = _state.value.pageIndex.coerceIn(0, lastPageIndex)
+            )
+        }
+    }
+
+    private fun setChapterState(chapterIndex: Int, chapter: MangaChapterState) {
+        val state = _state.value
+        _state.value = when (chapterIndex - state.chapterIndex) {
+            -1 -> state.copy(previousChapter = chapter)
+            0 -> state.copy(currentChapter = chapter)
+            1 -> state.copy(nextChapter = chapter)
+            else -> state
+        }
+    }
+
+    private fun retryChapter(chapterIndex: Int) {
+        val state = _state.value
+        val existing = when (chapterIndex - state.chapterIndex) {
+            -1 -> state.previousChapter
+            0 -> state.currentChapter
+            1 -> state.nextChapter
+            else -> return
+        }
+        if (existing is MangaChapterState.Loading) return
+        loadChapter(chapterIndex)
+    }
+
+    private suspend fun visiblePageChanged(command: MangaSessionCommand.VisiblePageChanged) {
+        val state = _state.value
+        if (command.chapterIndex != state.chapterIndex) return
+        val pageIndex = command.pageIndex.coerceAtLeast(0)
+        _state.value = state.copy(pageIndex = pageIndex)
+        val chapter = state.currentChapter as? MangaChapterState.Ready ?: return
+        val lastPageIndex = chapter.chapter.pages.lastIndex
+        if (pageIndex < ADJACENT_LOAD_DISTANCE) loadChapter(state.chapterIndex - 1)
+        if (pageIndex >= lastPageIndex - ADJACENT_LOAD_DISTANCE + 1) {
+            loadChapter(state.chapterIndex + 1)
+        }
+        scheduleProgressPersist()
+    }
+
+    private suspend fun applyProgress(command: MangaSessionCommand.ApplyProgress) {
+        val book = _state.value.book ?: return
+        withContext(ioDispatcher) { dataGateway.applyProgress(book.bookUrl, command.progress) }
+        val chapterCount = _state.value.chapterCount
+        if (chapterCount == 0) return
+        moveTo(
+            command.progress.chapterIndex.coerceIn(0, chapterCount - 1),
+            command.progress.pageIndex,
+            persist = false,
+        )
+    }
+
+    private suspend fun persistProgress() {
+        val state = _state.value
+        val book = state.book ?: return
+        withContext(ioDispatcher) {
+            dataGateway.persistProgress(book.bookUrl, state.chapterIndex, state.pageIndex)
+        }
+        lastProgressPersistAt = System.currentTimeMillis()
+    }
+
+    private suspend fun scheduleProgressPersist() {
+        if (scheduledProgressPersistJob?.isActive == true) return
+        val remainingDelay = (lastProgressPersistAt + PROGRESS_PERSIST_INTERVAL_MS -
+            System.currentTimeMillis()).coerceAtLeast(0L)
+        if (remainingDelay == 0L) {
+            persistProgress()
+            return
+        }
+        scheduledProgressPersistJob = sessionScope.launch {
+            delay(remainingDelay)
+            commands.trySend(MangaSessionCommand.PersistVisibleProgress)
+        }
+    }
+
+    private fun cancelScheduledProgressPersist() {
+        scheduledProgressPersistJob?.cancel()
+        scheduledProgressPersistJob = null
+    }
+
+    private fun resetLoadScope() {
+        loadJob.cancel()
+        loadJob = SupervisorJob(sessionJob)
+        loadScope = CoroutineScope(loadJob + ioDispatcher)
+    }
+
+    private suspend fun syncProgress() {
+        val book = _state.value.book ?: return
+        withContext(ioDispatcher) { dataGateway.syncProgress(book.bookUrl) }?.let {
+            _events.tryEmit(MangaSessionEvent.ConfirmProgress(it))
+        }
+    }
+
+    private fun MangaSessionState.chapterStateAt(chapterIndex: Int): MangaChapterState? = when (
+        chapterIndex - this.chapterIndex
+    ) {
+        -1 -> previousChapter
+        0 -> currentChapter
+        1 -> nextChapter
+        else -> null
+    }
+
+    override fun close() {
+        commands.trySend(MangaSessionCommand.Close)
+    }
+
+    private suspend fun shutdown() {
+        presentationJob?.cancel()
+        cancelScheduledProgressPersist()
+        persistProgress()
+        _state.value.book?.let {
+            withContext(ioDispatcher) { dataGateway.pause(it.bookUrl, it.inBookshelf) }
+        }
+        commands.close()
+        sessionScope.cancel()
+    }
+}

+ 169 - 0
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderActionRepository.kt

@@ -0,0 +1,169 @@
+package io.legado.app.data.repository.manga
+
+import android.app.Application
+import android.graphics.Bitmap
+import coil3.ImageLoader
+import coil3.request.ImageRequest
+import coil3.request.allowHardware
+import coil3.toBitmap
+import io.legado.app.R
+import io.legado.app.constant.BookType
+import io.legado.app.constant.EventBus
+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.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.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.webBook.WebBook
+import io.legado.app.utils.ImageSaveUtils
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isTrue
+import io.legado.app.utils.postEvent
+import kotlinx.coroutines.currentCoroutineContext
+import java.io.ByteArrayOutputStream
+
+sealed interface MangaReaderActionPaymentResult {
+    data class OpenUrl(
+        val url: String,
+        val sourceOrigin: String?,
+        val sourceName: String?,
+        val sourceType: Int?,
+    ) : MangaReaderActionPaymentResult
+
+    data object Refreshed : MangaReaderActionPaymentResult
+}
+
+/** One-shot reader operations. Every operation receives explicit session identity. */
+class MangaReaderActionRepository(
+    private val application: Application,
+    private val database: AppDatabase,
+    private val imageLoader: ImageLoader,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
+) {
+    suspend fun getBook(bookUrl: String): Book? = database.bookDao.getBook(bookUrl)
+
+    suspend fun refreshSource(sourceOrigin: String?) =
+        sourceOrigin?.let { database.bookSourceDao.getBookSource(it) }
+
+    suspend fun disableSource(sourceOrigin: String?) {
+        val source = sourceOrigin?.let { database.bookSourceDao.getBookSource(it) } ?: return
+        source.enabled = false
+        database.bookSourceDao.update(source)
+    }
+
+    suspend fun changeSource(currentBookUrl: String, book: Book, toc: List<BookChapter>) {
+        database.bookDao.getBook(currentBookUrl)?.migrateTo(
+            newBook = book,
+            toc = toc,
+            defaultReplaceEnabled = otherSettingsGateway.currentSettings.replaceEnableDefault,
+            chineseConverterType = readSettingsGateway.currentSettings.chineseConverterType,
+        )
+        book.removeType(BookType.updateError)
+        database.bookDao.getBook(currentBookUrl)?.delete()
+        database.bookDao.insert(book)
+        database.bookChapterDao.insert(*toc.toTypedArray())
+        postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
+    }
+
+    suspend fun removeTemporaryBook(bookUrl: String) {
+        database.bookDao.getBook(bookUrl)?.delete()
+    }
+
+    suspend fun addCurrentBookToShelf(bookUrl: String) {
+        val book = database.bookDao.getBook(bookUrl)
+            ?: throw NoStackTraceException(application.getString(R.string.no_book))
+        persistOnShelf(book, database.bookChapterDao.getChapterList(book.bookUrl))
+    }
+
+    suspend fun addToShelf(book: Book, toc: List<BookChapter>) = persistOnShelf(book, toc)
+
+    private suspend fun persistOnShelf(book: Book, toc: List<BookChapter>) {
+        book.removeType(BookType.notShelf)
+        if (book.order == 0) book.order = database.bookDao.minOrder - 1
+        database.bookDao.insert(book)
+        database.bookChapterDao.insert(*toc.toTypedArray())
+    }
+
+    suspend fun updateReadConfig(bookUrl: String, update: ReadConfig.() -> Unit) {
+        val book = database.bookDao.getBook(bookUrl) ?: return
+        book.readConfig = (book.readConfig ?: ReadConfig()).apply(update)
+        database.bookDao.update(book)
+    }
+
+    suspend fun invalidateChapter(bookUrl: String, chapterIndex: Int) {
+        val book = database.bookDao.getBook(bookUrl) ?: return
+        database.bookChapterDao.getChapter(bookUrl, chapterIndex)?.let {
+            BookHelp.delContent(book, it)
+        }
+    }
+
+    suspend fun saveImage(
+        url: String,
+        bookUrl: String,
+        sourceOrigin: String?,
+        folderName: String = "Legado",
+    ): Boolean {
+        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()
+        )
+        val bitmap = requireNotNull(result.image).toBitmap()
+        val bytes = ByteArrayOutputStream().use { output ->
+            bitmap.compress(Bitmap.CompressFormat.JPEG, 100, output)
+            output.toByteArray()
+        }
+        return ImageSaveUtils.saveImageToGallery(application, bytes, folderName = folderName)
+    }
+
+    suspend fun payCurrentChapter(
+        bookUrl: String,
+        chapterIndex: Int,
+    ): MangaReaderActionPaymentResult {
+        val book = database.bookDao.getBook(bookUrl)
+            ?: throw NoStackTraceException(application.getString(R.string.no_book))
+        if (book.isLocal) throw NoStackTraceException("local book")
+        val chapter = database.bookChapterDao.getChapter(bookUrl, chapterIndex)
+            ?: throw NoStackTraceException("no chapter")
+        val source = database.bookSourceDao.getBookSource(book.origin)
+            ?: throw NoStackTraceException("no book source")
+        val payAction = source.getContentRule().payAction
+        if (payAction.isNullOrBlank()) throw NoStackTraceException("no pay action")
+        val analyzeRule = AnalyzeRule(book, source).apply {
+            setCoroutineContext(currentCoroutineContext())
+            setBaseUrl(chapter.url)
+            setChapter(chapter)
+        }
+        val result = analyzeRule.evalJS(payAction).toString()
+        return when {
+            result.isAbsUrl() -> MangaReaderActionPaymentResult.OpenUrl(
+                result, source.bookSourceUrl, source.bookSourceName, source.getSourceType(),
+            )
+            result.isTrue() -> {
+                BookHelp.delContent(book, chapter)
+                val chapters = WebBook.getChapterListAwait(source, book, true).getOrThrow()
+                database.bookChapterDao.delByBook(book.bookUrl)
+                database.bookChapterDao.insert(*chapters.toTypedArray())
+                MangaReaderActionPaymentResult.Refreshed
+            }
+            else -> throw NoStackTraceException("pay action returned $result")
+        }
+    }
+}

+ 265 - 0
app/src/main/java/io/legado/app/data/repository/manga/MangaReaderDataRepository.kt

@@ -0,0 +1,265 @@
+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.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.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.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.exception.NoStackTraceException
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.isLocal
+import io.legado.app.help.book.isLocalModified
+import io.legado.app.help.book.readSimulating
+import io.legado.app.help.book.simulatedTotalChapterNum
+import io.legado.app.help.source.getSourceType
+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,
+    private val database: AppDatabase,
+    private val getReadingProgressUseCase: GetReadingProgressUseCase,
+    private val uploadReadingProgressUseCase: UploadReadingProgressUseCase,
+    private val backupSettingsGateway: BackupSettingsGateway,
+    private val readRecordRepository: ReadRecordRepository,
+) : MangaReaderDataGateway {
+
+    private var readingStartedAt: Long? = null
+
+    override fun observeBookPresentation(bookUrl: String) =
+        database.bookDao.flowGetBook(bookUrl)
+            .map { book ->
+                MangaBookPresentation(
+                    scrollMode = book?.readConfig?.mangaScrollMode,
+                    sidePaddingDp = book?.readConfig?.webtoonSidePaddingDp,
+                )
+            }
+            .distinctUntilChanged()
+
+    override suspend fun openBook(
+        bookUrl: String?,
+        inBookshelf: Boolean,
+        chapterChanged: Boolean,
+    ): OpenedMangaBook {
+        val book = bookUrl?.takeIf(String::isNotEmpty)?.let(database.bookDao::getBook)
+            ?: database.bookDao.lastReadBook
+            ?: throw NoStackTraceException(application.getString(R.string.no_book))
+        if (book.isLocal && !localBookExists(book)) {
+            throw NoStackTraceException(application.getString(R.string.no_book))
+        }
+        val source = database.bookSourceDao.getBookSource(book.origin)
+        if (!book.isLocal && book.tocUrl.isEmpty()) {
+            val activeSource = source ?: throw NoStackTraceException(
+                application.getString(R.string.manga_reader_details_failed),
+            )
+            val oldBook = book.copy()
+            WebBook.getBookInfoAwait(activeSource, book, canReName = false)
+            if (oldBook.bookUrl == book.bookUrl) database.bookDao.update(book)
+            else {
+                database.bookDao.replace(oldBook, book)
+                BookHelp.updateCacheFolder(oldBook, book)
+            }
+        }
+        var chapterCount = database.bookChapterDao.getChapterCount(book.bookUrl)
+        if (chapterCount == 0 || book.isLocalModified()) {
+            if (book.isLocal) {
+                val chapters = LocalBook.getChapterList(book)
+                database.bookChapterDao.delByBook(book.bookUrl)
+                database.bookChapterDao.insert(*chapters.toTypedArray())
+                database.bookDao.update(book)
+                chapterCount = chapters.size
+            } else {
+                val activeSource = source ?: throw NoStackTraceException(
+                    application.getString(R.string.error_load_toc),
+                )
+                val oldBook = book.copy()
+                val chapters = WebBook.getChapterListAwait(activeSource, book, true).getOrThrow()
+                if (oldBook.bookUrl == book.bookUrl) {
+                    database.bookDao.update(book)
+                } else {
+                    database.bookDao.replace(oldBook, book)
+                    BookHelp.updateCacheFolder(oldBook, book)
+                }
+                database.bookChapterDao.delByBook(oldBook.bookUrl)
+                database.bookChapterDao.insert(*chapters.toTypedArray())
+                chapterCount = chapters.size
+            }
+        }
+        if (chapterCount == 0) chapterCount = database.bookChapterDao.getChapterCount(book.bookUrl)
+        val simulatedCount = if (book.readSimulating()) {
+            book.simulatedTotalChapterNum()
+        } else chapterCount
+        val safeChapter = book.durChapterIndex.coerceIn(0, (simulatedCount - 1).coerceAtLeast(0))
+        val newerProgress = if (!chapterChanged) findNewerProgress(book) else null
+        return OpenedMangaBook(
+            book = MangaBookState(
+                bookUrl = book.bookUrl,
+                name = book.name,
+                author = book.author,
+                coverUrl = book.coverUrl,
+                customCoverUrl = book.customCoverUrl,
+                sourceOrigin = book.origin,
+                sourceName = source?.bookSourceName.orEmpty(),
+                sourceType = source?.getSourceType(),
+                inBookshelf = inBookshelf,
+                scrollMode = book.readConfig?.mangaScrollMode,
+                sidePaddingDp = book.readConfig?.webtoonSidePaddingDp,
+                chapterTitles = database.bookChapterDao.getChapterList(book.bookUrl).map { it.title },
+            ),
+            chapterIndex = safeChapter,
+            pageIndex = book.durChapterPos.coerceAtLeast(0),
+            chapterCount = simulatedCount,
+            newerProgress = newerProgress,
+        )
+    }
+
+    override suspend fun loadChapter(bookUrl: String, chapterIndex: Int): MangaChapterContent {
+        val book = database.bookDao.getBook(bookUrl)
+            ?: 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,
+        )
+    }
+
+    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)
+    }
+
+    override suspend fun persistProgress(bookUrl: String, chapterIndex: Int, pageIndex: Int) {
+        val book = database.bookDao.getBook(bookUrl) ?: return
+        book.durChapterIndex = chapterIndex
+        book.durChapterPos = pageIndex
+        book.durChapterTime = System.currentTimeMillis()
+        database.bookChapterDao.getChapter(bookUrl, chapterIndex)?.let {
+            book.durChapterTitle = it.title
+        }
+        database.bookDao.update(book)
+    }
+
+    override suspend fun applyProgress(bookUrl: String, progress: MangaProgressState) {
+        persistProgress(bookUrl, progress.chapterIndex, progress.pageIndex)
+    }
+
+    override suspend fun resume(bookUrl: String) {
+        if (readingStartedAt == null) readingStartedAt = System.currentTimeMillis()
+    }
+
+    override suspend fun pause(bookUrl: String, inBookshelf: Boolean) {
+        val start = readingStartedAt ?: return
+        readingStartedAt = null
+        val end = System.currentTimeMillis()
+        val book = database.bookDao.getBook(bookUrl) ?: return
+        if (end - start >= 10_000) {
+            readRecordRepository.saveReadSession(
+                ReadRecordSession(
+                    bookName = book.name,
+                    bookAuthor = book.author,
+                    startTime = start,
+                    endTime = end,
+                )
+            )
+        }
+        if (backupSettingsGateway.currentSettings.syncBookProgressPlus) {
+            uploadReadingProgressUseCase.execute(book.toProgressState())
+        }
+        if (inBookshelf && !BuildConfig.DEBUG) Backup.autoBack(application)
+    }
+
+    override suspend fun syncProgress(bookUrl: String): MangaProgressState? {
+        if (!backupSettingsGateway.currentSettings.syncBookProgressPlus) return null
+        val book = database.bookDao.getBook(bookUrl) ?: return null
+        return syncOrUpload(book)
+    }
+
+    private suspend fun findNewerProgress(book: Book): MangaProgressState? {
+        if (!backupSettingsGateway.currentSettings.syncBookProgress) return null
+        return syncOrUpload(book)
+    }
+
+    private suspend fun syncOrUpload(book: Book): MangaProgressState? {
+        val newer = compareProgress(
+            book,
+            getReadingProgressUseCase.execute(book.name, book.author),
+        )
+        if (newer == null) uploadReadingProgressUseCase.execute(book.toProgressState())
+        return newer
+    }
+
+    private fun compareProgress(
+        book: Book,
+        progress: io.legado.app.domain.model.ReadingProgress?,
+    ): MangaProgressState? {
+        progress ?: return null
+        val remoteIsNewer = progress.durChapterIndex > book.durChapterIndex ||
+            progress.durChapterIndex == book.durChapterIndex &&
+            progress.durChapterPos > book.durChapterPos
+        return progress.takeIf { remoteIsNewer }?.let {
+            MangaProgressState(
+                bookName = it.name,
+                bookAuthor = it.author,
+                chapterIndex = it.durChapterIndex,
+                pageIndex = it.durChapterPos,
+                chapterTitle = it.durChapterTitle,
+                updatedAt = it.durChapterTime,
+            )
+        }
+    }
+
+    private fun Book.toProgressState() = io.legado.app.domain.model.ReadingProgress(
+        name = name,
+        author = author,
+        durChapterIndex = durChapterIndex,
+        durChapterPos = durChapterPos,
+        durChapterTime = durChapterTime,
+        durChapterTitle = durChapterTitle,
+    )
+
+    private fun localBookExists(book: Book): Boolean = runCatching {
+        LocalBook.getBookInputStream(book)
+    }.isSuccess
+}

+ 18 - 2
app/src/main/java/io/legado/app/di/appModule.kt

@@ -68,7 +68,11 @@ import io.legado.app.data.repository.LocalPasswordRepository
 import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.OtherConfigSystemRepository
 import io.legado.app.data.repository.OtherSettingsRepository
-import io.legado.app.data.repository.MangaReaderSessionRepository
+import io.legado.app.data.repository.manga.DefaultMangaReaderSession
+import io.legado.app.data.repository.manga.MangaReaderActionRepository
+import io.legado.app.data.repository.manga.MangaReaderDataRepository
+import io.legado.app.domain.gateway.MangaReaderDataGateway
+import io.legado.app.domain.gateway.MangaReaderSessionFactory
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -312,6 +316,7 @@ import org.koin.core.module.dsl.factoryOf
 import org.koin.core.module.dsl.viewModel
 import org.koin.core.module.dsl.viewModelOf
 import org.koin.dsl.module
+import kotlinx.coroutines.Dispatchers
 import java.time.Clock
 
 val appModule = module {
@@ -395,7 +400,18 @@ val appModule = module {
     singleOf(::ReadStyleConfigStore)
     singleOf(::ReadBookStyleConfigRepository)
     single<ReadStyleGateway> { get<ReadBookStyleConfigRepository>() }
-    factoryOf(::MangaReaderSessionRepository)
+    factoryOf(::MangaReaderDataRepository)
+    factoryOf(::MangaReaderActionRepository)
+    factory<MangaReaderDataGateway> { get<MangaReaderDataRepository>() }
+    factory<MangaReaderSessionFactory> {
+        MangaReaderSessionFactory {
+            DefaultMangaReaderSession(
+                dataGateway = get<MangaReaderDataGateway>(),
+                stateDispatcher = Dispatchers.Default,
+                ioDispatcher = Dispatchers.IO,
+            )
+        }
+    }
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)

+ 35 - 0
app/src/main/java/io/legado/app/domain/gateway/MangaReaderDataGateway.kt

@@ -0,0 +1,35 @@
+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.MangaProgressState
+import io.legado.app.domain.model.manga.OpenedMangaBook
+import kotlinx.coroutines.flow.Flow
+
+interface MangaReaderDataGateway {
+    fun observeBookPresentation(bookUrl: String): Flow<MangaBookPresentation>
+
+    suspend fun openBook(
+        bookUrl: String?,
+        inBookshelf: Boolean,
+        chapterChanged: Boolean,
+    ): OpenedMangaBook
+
+    suspend fun loadChapter(bookUrl: String, chapterIndex: Int): MangaChapterContent
+
+    suspend fun prefetchChapter(bookUrl: String, chapterIndex: Int)
+
+    suspend fun persistProgress(
+        bookUrl: String,
+        chapterIndex: Int,
+        pageIndex: Int,
+    )
+
+    suspend fun applyProgress(bookUrl: String, progress: MangaProgressState)
+
+    suspend fun resume(bookUrl: String)
+
+    suspend fun pause(bookUrl: String, inBookshelf: Boolean)
+
+    suspend fun syncProgress(bookUrl: String): MangaProgressState?
+}

+ 14 - 0
app/src/main/java/io/legado/app/domain/gateway/MangaReaderSession.kt

@@ -0,0 +1,14 @@
+package io.legado.app.domain.gateway
+
+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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+
+interface MangaReaderSession : AutoCloseable {
+    val state: StateFlow<MangaSessionState>
+    val events: Flow<MangaSessionEvent>
+
+    suspend fun execute(command: MangaSessionCommand)
+}

+ 5 - 0
app/src/main/java/io/legado/app/domain/gateway/MangaReaderSessionFactory.kt

@@ -0,0 +1,5 @@
+package io.legado.app.domain.gateway
+
+fun interface MangaReaderSessionFactory {
+    fun create(): MangaReaderSession
+}

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

@@ -0,0 +1,146 @@
+package io.legado.app.domain.model.manga
+
+import java.util.UUID
+
+@JvmInline
+value class MangaSessionId(val value: String) {
+    companion object {
+        fun create(): MangaSessionId = MangaSessionId(UUID.randomUUID().toString())
+    }
+}
+
+data class MangaBookState(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val coverUrl: String?,
+    val customCoverUrl: String?,
+    val sourceOrigin: String?,
+    val sourceName: String,
+    val sourceType: Int?,
+    val inBookshelf: Boolean,
+    val scrollMode: Int?,
+    val sidePaddingDp: Int?,
+    /** TOC titles are retained so a chapter transition stays informative before pages load. */
+    val chapterTitles: List<String>,
+)
+
+data class MangaBookPresentation(
+    val scrollMode: Int?,
+    val sidePaddingDp: Int?,
+)
+
+data class OpenedMangaBook(
+    val book: MangaBookState,
+    val chapterIndex: Int,
+    val pageIndex: Int,
+    val chapterCount: Int,
+    val newerProgress: MangaProgressState? = null,
+)
+
+data class MangaLoadToken(
+    val sessionId: MangaSessionId,
+    val revision: Long,
+    val bookUrl: String,
+    val chapterIndex: Int,
+)
+
+sealed interface MangaChapterState {
+    data object Empty : MangaChapterState
+    data class Loading(val token: MangaLoadToken) : MangaChapterState
+    data class Ready(
+        val token: MangaLoadToken,
+        val chapter: MangaChapterContent,
+    ) : MangaChapterState
+    data class Failed(
+        val token: MangaLoadToken,
+        val message: String,
+        val retryable: Boolean = true,
+    ) : MangaChapterState
+}
+
+data class MangaSessionState(
+    val sessionId: MangaSessionId,
+    val revision: Long = 0,
+    val book: MangaBookState? = null,
+    val chapterIndex: Int = 0,
+    val pageIndex: Int = 0,
+    val chapterCount: Int = 0,
+    val previousChapter: MangaChapterState = MangaChapterState.Empty,
+    val currentChapter: MangaChapterState = MangaChapterState.Empty,
+    val nextChapter: MangaChapterState = MangaChapterState.Empty,
+    val resumed: Boolean = false,
+    val openError: String? = null,
+) {
+    fun accepts(token: MangaLoadToken): Boolean =
+        token.sessionId == sessionId &&
+            token.revision == revision &&
+            token.bookUrl == book?.bookUrl
+
+    fun tokenFor(chapterIndex: Int): MangaLoadToken? = book?.let {
+        MangaLoadToken(sessionId, revision, it.bookUrl, chapterIndex)
+    }
+}
+
+sealed interface MangaSessionCommand {
+    data class Open(
+        val bookUrl: String?,
+        val inBookshelf: Boolean,
+        val chapterChanged: Boolean,
+    ) : MangaSessionCommand
+    data class OpenChapter(val chapterIndex: Int, val pageIndex: Int = 0) : MangaSessionCommand
+    data object PreviousChapter : MangaSessionCommand
+    data object NextChapter : MangaSessionCommand
+    data class PromoteVisibleChapter(val chapterIndex: Int, val pageIndex: Int) : MangaSessionCommand
+    data class VisiblePageChanged(val chapterIndex: Int, val pageIndex: Int) : MangaSessionCommand
+    data object PersistVisibleProgress : MangaSessionCommand
+    data class RetryChapter(val chapterIndex: Int) : MangaSessionCommand
+    data class PrefetchCountChanged(val count: Int) : MangaSessionCommand
+    data class BookPresentationObserved(
+        val bookUrl: String,
+        val presentation: MangaBookPresentation,
+    ) : MangaSessionCommand
+    data class ApplyProgress(val progress: MangaProgressState) : MangaSessionCommand
+    data class ChapterLoaded(
+        val token: MangaLoadToken,
+        val result: Result<MangaChapterContent>,
+    ) : MangaSessionCommand
+    data object Resume : MangaSessionCommand
+    data object Pause : MangaSessionCommand
+    data object NetworkAvailable : MangaSessionCommand
+    data object Close : MangaSessionCommand
+}
+
+sealed interface MangaSessionEvent {
+    data class ConfirmProgress(val progress: MangaProgressState) : MangaSessionEvent
+    data class Message(val message: String) : MangaSessionEvent
+    data class OpenPaymentUrl(
+        val url: String,
+        val sourceOrigin: String?,
+        val sourceName: String?,
+        val sourceType: Int?,
+    ) : MangaSessionEvent
+}
+
+data class MangaProgressState(
+    val bookName: String,
+    val bookAuthor: String,
+    val chapterIndex: Int,
+    val pageIndex: Int,
+    val chapterTitle: String?,
+    val updatedAt: Long,
+)
+
+data class MangaChapterContent(
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapterUrl: String?,
+    val pages: List<MangaPageContent>,
+    val isVolume: Boolean,
+)
+
+data class MangaPageContent(
+    val imageUrl: String,
+    val pageIndex: Int,
+    val pageCount: Int,
+)

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

@@ -12,6 +12,11 @@ data class MangaSettings(
     val disableClickScroll: Boolean = false,
     val longClick: Boolean = true,
     val background: Int = 0xFF000000.toInt(),
+    val autoBackground: Boolean = false,
+    val pageScaleType: Int = 0,
+    val zoomStartPosition: Int = 0,
+    val widePageMode: Int = 0,
+    val doublePageMode: Int = 0,
     val colorFilter: String = "",
     val hideTitle: Boolean = false,
     val enableEInk: Boolean = false,
@@ -20,6 +25,14 @@ data class MangaSettings(
     val webtoonSidePaddingDp: Int = 0,
     val volumeKeyPage: Boolean = false,
     val reverseVolumeKeyPage: Boolean = false,
+    val menuTopBarLiquidGlass: Boolean = false,
+    val menuBottomBarLiquidGlass: Boolean = false,
+    val menuBottomBarFloating: Boolean = true,
+    val menuBottomBarBlur: Boolean = false,
+    val menuTopBarCompact: Boolean = false,
+    val menuColorSource: Int = 0,
+    val menuSeedColor: Int = 0xFF6750A4.toInt(),
+    val menuPaletteStyle: String = "tonalSpot",
     val clickActionTL: Int = -1,
     val clickActionTC: Int = -1,
     val clickActionTR: Int = 1,

+ 3 - 0
app/src/main/java/io/legado/app/help/coil/CoverExtras.kt

@@ -23,4 +23,7 @@ object CoverExtras {
 
     /** 漫画模式。 */
     val Manga = Extras.Key<Boolean?>(null)
+
+    /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
+    val MangaBookUrl = Extras.Key<String?>(null)
 }

+ 14 - 2
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -8,9 +8,10 @@ import coil3.fetch.FetchResult
 import coil3.fetch.Fetcher
 import coil3.fetch.SourceFetchResult
 import coil3.request.Options
-import io.legado.app.model.ReadManga
+import io.legado.app.data.appDb
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.isWifiConnect
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import okhttp3.CacheControl
@@ -52,6 +53,10 @@ class CoverFetcher(
             failCache[url] = System.currentTimeMillis()
         }
 
+        fun clearFailure(url: String) {
+            failCache.remove(url)
+        }
+
         fun clearFailCache() {
             failCache.clear()
         }
@@ -60,6 +65,8 @@ class CoverFetcher(
     override suspend fun fetch(): FetchResult {
         val source = options.extras[CoverExtras.Source]
         val isManga = options.extras[CoverExtras.Manga] == true
+        val mangaBook = options.extras[CoverExtras.MangaBookUrl]
+            ?.let { bookUrl -> withContext(Dispatchers.IO) { appDb.bookDao.getBook(bookUrl) } }
 
         if (url.startsWith("data:", true)) {
             val base64Data = url.substringAfter("base64,", "")
@@ -89,6 +96,7 @@ class CoverFetcher(
         val requestHeaders = options.extras[CoverExtras.Headers]
         val cacheRequest = Request.Builder()
             .url(url)
+            .tag(io.legado.app.data.entities.BaseSource::class.java, source)
             .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
             .cacheControl(CacheControl.FORCE_CACHE)
             .build()
@@ -105,6 +113,7 @@ class CoverFetcher(
                     // Cache miss, fetch from network
                     val networkRequest = Request.Builder()
                         .url(url)
+                        .tag(io.legado.app.data.entities.BaseSource::class.java, source)
                         .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                         .tag(COVER_REQUEST_TAG)
                         .cacheControl(
@@ -122,6 +131,8 @@ class CoverFetcher(
                     body.use { it.bytes() }
                 }
             }
+        } catch (e: CancellationException) {
+            throw e
         } catch (e: Exception) {
             markFailed(url)
             throw e
@@ -133,13 +144,14 @@ class CoverFetcher(
         } else {
             withContext(Dispatchers.IO) {
                 if (isManga) {
-                    ImageUtils.decode(url, rawBytes, false, source, ReadManga.book)
+                    ImageUtils.decode(url, rawBytes, false, source, mangaBook)
                 } else {
                     ImageUtils.decode(url, rawBytes, true, source)
                 }
             } ?: throw IOException("图片解密失败")
         }
 
+        clearFailure(url)
         return SourceFetchResult(
             source = ImageSource(
                 source = Buffer().write(decodedBytes),

+ 19 - 5
app/src/main/java/io/legado/app/help/glide/LegadoDataUrlLoader.kt

@@ -9,7 +9,8 @@ import com.bumptech.glide.load.model.ModelLoaderFactory
 import com.bumptech.glide.load.model.MultiModelLoaderFactory
 import com.bumptech.glide.signature.ObjectKey
 import io.legado.app.exception.NoStackTraceException
-import io.legado.app.model.ReadManga
+import io.legado.app.data.appDb
+import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.utils.ImageUtils
 import com.script.rhino.runScriptWithContext
@@ -27,14 +28,25 @@ class LegadoDataUrlLoader : ModelLoader<String, InputStream> {
         if (options.get(OkHttpModelLoader.mangaOption) == false) {
             return null
         }
-        return ModelLoader.LoadData(ObjectKey(model), LegadoDataUrlFetcher(model))
+        return ModelLoader.LoadData(
+            ObjectKey(model),
+            LegadoDataUrlFetcher(
+                model = model,
+                sourceOrigin = options.get(OkHttpModelLoader.sourceOriginOption),
+                bookUrl = options.get(OkHttpModelLoader.mangaBookUrlOption),
+            ),
+        )
     }
 
     override fun handles(model: String): Boolean {
         return model.startsWith("data:")
     }
 
-    class LegadoDataUrlFetcher(private val model: String) : DataFetcher<InputStream> {
+    class LegadoDataUrlFetcher(
+        private val model: String,
+        private val sourceOrigin: String?,
+        private val bookUrl: String?,
+    ) : DataFetcher<InputStream> {
 
         private val coroutineContext = Job()
 
@@ -43,13 +55,15 @@ class LegadoDataUrlLoader : ModelLoader<String, InputStream> {
             callback: DataFetcher.DataCallback<in InputStream>
         ) {
             try {
+                val source = SourceHelp.getSource(sourceOrigin)
+                val book = bookUrl?.let(appDb.bookDao::getBook)
                 val bytes = AnalyzeUrl(
-                    model, source = ReadManga.bookSource,
+                    model, source = source,
                     coroutineContext = coroutineContext
                 ).getByteArray()
                 val decoded = runScriptWithContext(coroutineContext) {
                     ImageUtils.decode(
-                        model, bytes, isCover = false, ReadManga.bookSource, ReadManga.book
+                        model, bytes, isCover = false, source, book
                     )?.inputStream()
                 }
                 if (decoded == null) {

+ 2 - 1
app/src/main/java/io/legado/app/help/glide/OkHttpModelLoader.kt

@@ -11,6 +11,7 @@ object OkHttpModelLoader : ModelLoader<GlideUrl?, InputStream?> {
     val loadOnlyWifiOption = Option.memory("loadOnlyWifi", false)
     val sourceOriginOption = Option.memory<String>("sourceOrigin")
     val mangaOption = Option.memory<Boolean>("manga",false)
+    val mangaBookUrlOption = Option.memory<String>("mangaBookUrl")
 
     override fun buildLoadData(
         model: GlideUrl,
@@ -25,4 +26,4 @@ object OkHttpModelLoader : ModelLoader<GlideUrl?, InputStream?> {
         return true
     }
 
-}
+}

+ 9 - 4
app/src/main/java/io/legado/app/help/glide/OkHttpStreamFetcher.kt

@@ -17,7 +17,7 @@ import io.legado.app.help.http.addHeaders
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
 import io.legado.app.help.source.SourceHelp
-import io.legado.app.model.ReadManga
+import io.legado.app.data.appDb
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.isWifiConnect
@@ -43,6 +43,7 @@ class OkHttpStreamFetcher(
     private var responseBody: ResponseBody? = null
     private var callback: DataFetcher.DataCallback<in InputStream>? = null
     private var source: BaseSource? = null
+    private var mangaBook: Book? = null
     private val manga = options.get(OkHttpModelLoader.mangaOption) == true
     private val coroutineContext = SupervisorJob()
     private val coroutineScope = CoroutineScope(coroutineContext)
@@ -70,11 +71,13 @@ class OkHttpStreamFetcher(
         options.get(OkHttpModelLoader.sourceOriginOption)?.let { sourceUrl ->
             source = SourceHelp.getSource(sourceUrl)
         }
+        mangaBook = options.get(OkHttpModelLoader.mangaBookUrlOption)
+            ?.let(appDb.bookDao::getBook)
 
         this.callback = callback
 
         if (manga) {
-            ReadManga.book?.let { book ->
+            mangaBook?.let { book ->
                 val src = url.toString()
                 if (BookHelp.isImageExist(book, src)) {
                     Coroutine.async(coroutineScope, executeContext = IO) {
@@ -91,7 +94,9 @@ class OkHttpStreamFetcher(
             coroutineContext = coroutineContext
         ).getGlideUrl()
 
-        val requestBuilder = Request.Builder().url(analyzedUrl.toStringUrl())
+        val requestBuilder = Request.Builder()
+            .url(analyzedUrl.toStringUrl())
+            .tag(BaseSource::class.java, source)
         requestBuilder.addHeaders(analyzedUrl.headers)
         val request: Request = requestBuilder.build()
         dataSource = DataSource.REMOTE
@@ -173,7 +178,7 @@ class OkHttpStreamFetcher(
                         responseBody!!.bytes(),
                         isCover = false,
                         source,
-                        ReadManga.book
+                        mangaBook
                     )?.inputStream()
                 } else {
                     ImageUtils.decode(

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

@@ -6,7 +6,8 @@ import io.legado.app.help.config.AppConfig
 import io.legado.app.help.glide.progress.ProgressManager.LISTENER
 import io.legado.app.help.glide.progress.ProgressResponseBody
 import io.legado.app.help.http.CookieManager.cookieJarHeader
-import io.legado.app.model.ReadManga
+import io.legado.app.data.entities.BaseSource
+import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.utils.NetworkUtils
 import okhttp3.Cache
 import okhttp3.ConnectionSpec
@@ -156,7 +157,7 @@ val okHttpClientManga by lazy {
                 .build()
         }
         interceptors.add(1) { chain ->
-            ReadManga.rateLimiter.withLimitBlocking {
+            ConcurrentRateLimiter(chain.request().tag(BaseSource::class.java)).withLimitBlocking {
                 chain.proceed(chain.request())
             }
         }
@@ -224,4 +225,4 @@ fun getProxyClient(proxy: String? = null): OkHttpClient {
         return proxyClient
     }
     return okHttpClient
-}
+}

+ 1 - 4
app/src/main/java/io/legado/app/help/source/SourceHelp.kt

@@ -11,7 +11,6 @@ import io.legado.app.help.config.SourceConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.ReadBook
-import io.legado.app.model.ReadManga
 import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.splitNotBlank
@@ -37,8 +36,6 @@ object SourceHelp {
             return ReadBook.bookSource
         } else if (AudioPlay.bookSource?.bookSourceUrl == key) {
             return AudioPlay.bookSource
-        } else if (ReadManga.bookSource?.bookSourceUrl == key) {
-            return ReadManga.bookSource
         }
         return appDb.bookSourceDao.getBookSource(key)
             ?: appDb.rssSourceDao.getByKey(key)
@@ -175,4 +172,4 @@ object SourceHelp {
         }
     }
 
-}
+}

+ 0 - 754
app/src/main/java/io/legado/app/model/ReadManga.kt

@@ -1,754 +0,0 @@
-package io.legado.app.model
-
-import io.legado.app.constant.AppLog
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookChapter
-import io.legado.app.data.entities.BookProgress
-import io.legado.app.data.entities.BookSource
-import io.legado.app.data.entities.readRecord.ReadRecord
-import io.legado.app.data.entities.readRecord.ReadRecordSession
-import io.legado.app.data.repository.ReadRecordRepository
-import io.legado.app.help.AppWebDav
-import io.legado.app.help.ConcurrentRateLimiter
-import io.legado.app.help.book.BookHelp
-import io.legado.app.help.book.ContentProcessor
-import io.legado.app.help.book.isImage
-import io.legado.app.help.book.isLocal
-import io.legado.app.help.book.isSameNameAuthor
-import io.legado.app.help.book.readSimulating
-import io.legado.app.help.book.simulatedTotalChapterNum
-import io.legado.app.help.book.update
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.globalExecutor
-import io.legado.app.model.webBook.WebBook
-import io.legado.app.model.manga.BaseMangaPage
-import io.legado.app.model.manga.MangaChapter
-import io.legado.app.model.manga.MangaContent
-import io.legado.app.model.manga.MangaPage
-import io.legado.app.model.manga.ReaderLoading
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
-import io.legado.app.utils.mapIndexed
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancelChildren
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Semaphore
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
-import kotlin.math.min
-
-@Suppress("MemberVisibilityCanBePrivate")
-object ReadManga : CoroutineScope by MainScope() , KoinComponent{
-    var inBookshelf = false
-    var book: Book? = null
-    val executor = globalExecutor
-    var durChapterIndex = 0 //章节位置
-    var chapterSize = 0//总章节
-    var durChapterPos = 0
-    var chapterChanged = false
-    var prevMangaChapter: MangaChapter? = null
-    var curMangaChapter: MangaChapter? = null
-    var nextMangaChapter: MangaChapter? = null
-    var bookSource: BookSource? = null
-    var readStartTime: Long = System.currentTimeMillis()
-    private val readRecord = ReadRecord()
-    private val loadingChapters = arrayListOf<Int>()
-    var simulatedChapterSize = 0
-    var mCallback: Callback? = null
-    var preDownloadTask: Job? = null
-    val downloadedChapters = hashSetOf<Int>()
-    val downloadFailChapters = hashMapOf<Int, Int>()
-    val downloadScope = CoroutineScope(SupervisorJob() + IO)
-    val preDownloadSemaphore = Semaphore(2)
-    var rateLimiter = ConcurrentRateLimiter(null)
-    val mangaContents get() = buildMangaContent()
-    val hasNextChapter get() = durChapterIndex < simulatedChapterSize - 1
-
-    private val readRecordRepository: ReadRecordRepository by inject()
-
-    private val ioScope = CoroutineScope(IO)
-
-    private var autoSaveJob: Job? = null
-
-    private var currentActiveSession: ReadRecordSession? = null
-    private var currentReadLength: Long = 10L
-    private const val AUTO_SAVE_INTERVAL = 120 * 1000L
-
-    private const val MIN_READ_DURATION = 10 * 1000L
-
-    private var lastReadLength: Long = 0
-    fun resetData(book: Book) {
-        ReadManga.book = book
-        readRecord.bookName = book.name
-        readRecord.bookAuthor = book.author
-        readRecord.readTime = appDb.readRecordDao.getReadTime("", book.name, book.author) ?: 0
-        chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
-        simulatedChapterSize = if (book.readSimulating()) {
-            book.simulatedTotalChapterNum()
-        } else {
-            chapterSize
-        }
-        durChapterIndex = book.durChapterIndex
-        durChapterPos = book.durChapterPos
-        clearMangaChapter()
-        upWebBook(book)
-        synchronized(this) {
-            loadingChapters.clear()
-            downloadedChapters.clear()
-            downloadFailChapters.clear()
-        }
-    }
-
-    fun upData(book: Book) {
-        ReadManga.book = book
-        chapterSize = appDb.bookChapterDao.getChapterCount(book.bookUrl)
-        simulatedChapterSize = if (book.readSimulating()) {
-            book.simulatedTotalChapterNum()
-        } else {
-            chapterSize
-        }
-
-        if (durChapterIndex != book.durChapterIndex) {
-            durChapterIndex = book.durChapterIndex
-            durChapterPos = book.durChapterPos
-            clearMangaChapter()
-        }
-        upWebBook(book)
-        synchronized(this) {
-            loadingChapters.clear()
-            downloadedChapters.clear()
-            downloadFailChapters.clear()
-        }
-    }
-
-    fun upWebBook(book: Book) {
-        appDb.bookSourceDao.getBookSource(book.origin)?.let {
-            bookSource = it
-            rateLimiter = ConcurrentRateLimiter(it)
-        } ?: let {
-            bookSource = null
-        }
-    }
-
-    fun clearMangaChapter() {
-        prevMangaChapter = null
-        curMangaChapter = null
-        nextMangaChapter = null
-    }
-
-    fun initReadTime() {
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        if (currentActiveSession != null &&
-            (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
-        ) {
-            commitReadSession()
-        }
-
-        if (currentActiveSession == null) {
-            lastReadLength = currentReadLength
-            currentActiveSession = ReadRecordSession(
-                deviceId = "",
-                bookName = currentBookName,
-                bookAuthor = currentBookAuthor,
-                startTime = readStartTime,
-                endTime = readStartTime,
-                words = durChapterIndex.toLong()
-            )
-        }
-    }
-
-    fun upReadTime() {
-        val currentLength = currentReadLength
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        val endTime = System.currentTimeMillis()
-
-        if (currentActiveSession == null ||
-            currentActiveSession!!.bookName != currentBookName ||
-            currentActiveSession!!.bookAuthor != currentBookAuthor
-        ) {
-            initReadTime()
-            return
-        }
-
-        currentActiveSession = currentActiveSession!!.copy(
-            endTime = endTime,
-            words = durChapterIndex.toLong()
-        )
-
-        readStartTime = endTime
-        lastReadLength = currentLength
-    }
-
-    fun startAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = ioScope.launch {
-            while (isActive) {
-                delay(AUTO_SAVE_INTERVAL)
-                commitSessionInternal()
-            }
-        }
-    }
-
-    fun stopAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = null
-    }
-
-    fun commitReadSession() {
-        ioScope.launch {
-            commitSessionInternal()
-        }
-    }
-
-    /**
-     * 内部提交逻辑
-     */
-    private suspend fun commitSessionInternal() {
-        val sessionToSave = currentActiveSession ?: return
-        val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
-        if (sessionDuration < MIN_READ_DURATION) {
-            currentActiveSession = null
-            return
-        }
-        try {
-            readRecordRepository.saveReadSession(sessionToSave)
-        } catch (e: Exception) {
-            AppLog.put("保存阅读会话出错: ${sessionToSave.bookName}", e)
-        } finally {
-            currentActiveSession = null
-        }
-    }
-
-    @Synchronized
-    private fun addLoading(index: Int): Boolean {
-        if (loadingChapters.contains(index)) return false
-        loadingChapters.add(index)
-        return true
-    }
-
-    @Synchronized
-    fun removeLoading(index: Int) {
-        loadingChapters.remove(index)
-    }
-
-    fun loadContent() {
-        clearMangaChapter()
-        loadContent(durChapterIndex)
-        loadContent(durChapterIndex + 1)
-        loadContent(durChapterIndex - 1)
-    }
-
-    fun loadOrUpContent() {
-        if (curMangaChapter == null) {
-            loadContent(durChapterIndex)
-        } else {
-            mCallback?.upContent()
-        }
-        if (nextMangaChapter == null) {
-            loadContent(durChapterIndex + 1)
-        }
-        if (prevMangaChapter == null) {
-            loadContent(durChapterIndex - 1)
-        }
-    }
-
-    private fun loadContent(index: Int) {
-        Coroutine.async {
-            val book = book!!
-            val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return@async
-            if (addLoading(index)) {
-                BookHelp.getContent(book, chapter)?.let {
-                    contentLoadFinish(chapter, it)
-                } ?: run {
-                    download(downloadScope, chapter)
-                }
-            }
-        }.onError {
-            AppLog.put("加载正文出错\n${it.localizedMessage}")
-        }
-    }
-
-    /**
-     * 内容加载完成
-     */
-    suspend fun contentLoadFinish(
-        chapter: BookChapter,
-        content: String?,
-        errorMsg: String = "加载内容失败",
-        canceled: Boolean = false
-    ) {
-        removeLoading(chapter.index)
-        if (canceled || chapter.index !in durChapterIndex - 1..durChapterIndex + 1) {
-            return
-        }
-        when (val offset = chapter.index - durChapterIndex) {
-            0 -> {
-                if (content == null) {
-                    mCallback?.loadFail(errorMsg)
-                    return
-                }
-                if (content.isEmpty() && !chapter.isVolume) {
-                    mCallback?.loadFail("正文内容为空")
-                    return
-                }
-                val mangaChapter = getManageChapter(chapter, content)
-                if (mangaChapter.imageCount == 0 && !chapter.isVolume) {
-                    mCallback?.loadFail("正文没有图片")
-                    return
-                }
-                curMangaChapter = mangaChapter
-                mCallback?.upContent()
-            }
-
-            -1, 1 -> {
-                if (content == null || (!chapter.isVolume && content.isEmpty())) {
-                    return
-                }
-                val mangaChapter = getManageChapter(chapter, content)
-                if (mangaChapter.imageCount == 0 && !chapter.isVolume) {
-                    return
-                }
-
-                when (offset) {
-                    -1 -> prevMangaChapter = mangaChapter
-                    1 -> nextMangaChapter = mangaChapter
-                }
-
-                mCallback?.upContent()
-            }
-        }
-    }
-
-    private fun buildMangaContent(): MangaContent {
-        val items = arrayListOf<BaseMangaPage>()
-        var pos = 0
-        var curFinish = false
-        var nextFinish = false
-        prevMangaChapter?.let {
-            pos += it.pages.size
-            items.addAll(it.pages)
-        }
-        curMangaChapter?.let {
-            curFinish = true
-            items.addAll(it.pages)
-            durChapterPos = if (it.imageCount > 0) {
-                durChapterPos.coerceIn(0, it.imageCount - 1)
-            } else {
-                0
-            }
-            pos += durChapterPos
-            if (!ReadMangaConfig.hideMangaTitle && it.imageCount > 0) {
-                pos++
-            }
-        }
-        nextMangaChapter?.let {
-            nextFinish = true
-            items.addAll(it.pages)
-        }
-        return MangaContent(pos, items, curFinish, nextFinish)
-    }
-
-    /**
-     * 加载下一章
-     */
-    fun moveToNextChapter(toFirst: Boolean = false): Boolean {
-        if (durChapterIndex < simulatedChapterSize - 1) {
-            if (toFirst) {
-                mCallback?.showLoading()
-            }
-            durChapterPos = 0
-            durChapterIndex++
-            prevMangaChapter = curMangaChapter
-            curMangaChapter = nextMangaChapter
-            nextMangaChapter = null
-            if (curMangaChapter == null) {
-                mCallback?.startLoad()
-                loadContent(durChapterIndex)
-            } else {
-                mCallback?.upContent()
-            }
-            loadContent(durChapterIndex + 1)
-            saveRead()
-            AppLog.putDebug("moveToNextChapter-curPageChanged()")
-            curPageChanged()
-            return true
-        } else {
-            AppLog.putDebug("跳转下一章失败,没有下一章")
-            return false
-        }
-    }
-
-    fun moveToPrevChapter(toFirst: Boolean = false): Boolean {
-        if (durChapterIndex > 0) {
-            if (toFirst) {
-                mCallback?.showLoading()
-            }
-            durChapterPos = 0
-            durChapterIndex--
-            nextMangaChapter = curMangaChapter
-            curMangaChapter = prevMangaChapter
-            prevMangaChapter = null
-            if (curMangaChapter == null) {
-                loadContent(durChapterIndex)
-            } else {
-                mCallback?.upContent()
-            }
-            loadContent(durChapterIndex - 1)
-            saveRead()
-            return true
-        }
-        return false
-    }
-
-    fun curPageChanged() {
-        upReadTime()
-        preDownload()
-    }
-
-    fun saveRead(pageChanged: Boolean = false) {
-        executor.execute {
-            kotlin.runCatching {
-                val book = book ?: return@execute
-                book.lastCheckCount = 0
-                book.durChapterTime = System.currentTimeMillis()
-                val chapterChanged = book.durChapterIndex != durChapterIndex
-                book.durChapterIndex = durChapterIndex
-                book.durChapterPos = durChapterPos
-                if (!pageChanged || chapterChanged) {
-                    appDb.bookChapterDao.getChapter(book.bookUrl, durChapterIndex)?.let {
-                        book.durChapterTitle = it.getDisplayTitle(
-                            ContentProcessor.get(book.name, book.origin).getTitleReplaceRules(),
-                            book.getUseReplaceRule(AppConfig.replaceEnableDefault),
-                            chineseConverterType = AppConfig.chineseConverterType,
-                        )
-                    }
-                }
-                appDb.bookDao.update(book)
-            }.onFailure {
-                AppLog.put("保存漫画阅读进度信息出错\n$it", it)
-            }
-        }
-    }
-
-    private fun downloadNetworkContent(
-        bookSource: BookSource,
-        scope: CoroutineScope,
-        chapter: BookChapter,
-        book: Book,
-        semaphore: Semaphore?,
-        success: suspend (String) -> Unit = {},
-        error: suspend () -> Unit = {},
-        cancel: suspend () -> Unit = {},
-    ) {
-        WebBook.getContent(
-            scope,
-            bookSource,
-            book,
-            chapter,
-            start = CoroutineStart.LAZY,
-            executeContext = IO,
-            semaphore = semaphore
-        ).onSuccess { content ->
-            success.invoke(content)
-        }.onError {
-            error.invoke()
-        }.onCancel {
-            cancel.invoke()
-        }.start()
-    }
-
-    /**
-     * @return true 表示非图片书,或图片已齐全 / 本次补全成功
-     */
-    private suspend fun cacheChapterImagesIfNeeded(
-        book: Book,
-        chapter: BookChapter,
-        content: String? = null,
-    ): Boolean {
-        if (!book.isImage) return true
-        val source = bookSource ?: return false
-        if (BookHelp.hasImageContent(book, chapter)) return true
-        val resolvedContent = content ?: BookHelp.getContent(book, chapter) ?: return false
-        val failures = BookHelp.saveImages(source, book, chapter, resolvedContent)
-        return BookHelp.isChapterImageCacheComplete(book, chapter, failures)
-    }
-
-    private fun preDownload() {
-        if (book?.isLocal == true) return
-        executor.execute {
-            if (ReadConfig.preDownloadNum < 2) {
-                return@execute
-            }
-            preDownloadTask?.cancel()
-            preDownloadTask = launch(IO) {
-                //预下载
-                launch {
-                    val maxChapterIndex =
-                        min(durChapterIndex + ReadConfig.preDownloadNum, chapterSize)
-                    for (i in durChapterIndex.plus(2)..maxChapterIndex) {
-                        if (downloadedChapters.contains(i)) continue
-                        if ((downloadFailChapters[i] ?: 0) >= 3) continue
-                        downloadIndex(i)
-                    }
-                }
-                launch {
-                    val minChapterIndex = durChapterIndex - min(5, ReadConfig.preDownloadNum)
-                    for (i in durChapterIndex.minus(2) downTo minChapterIndex) {
-                        if (downloadedChapters.contains(i)) continue
-                        if ((downloadFailChapters[i] ?: 0) >= 3) continue
-                        downloadIndex(i)
-                    }
-                }
-            }
-        }
-    }
-
-    fun cancelPreDownloadTask() {
-        if (curMangaChapter != null && nextMangaChapter != null) {
-            preDownloadTask?.cancel()
-            downloadScope.coroutineContext.cancelChildren()
-        }
-    }
-
-    private suspend fun downloadIndex(index: Int) {
-        if (index < 0) return
-        if (index > chapterSize - 1) {
-            upToc()
-            return
-        }
-        val book = book ?: return
-        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index) ?: return
-        if (BookHelp.hasContent(book, chapter)) {
-            if (cacheChapterImagesIfNeeded(book, chapter)) {
-                downloadedChapters.add(chapter.index)
-                downloadFailChapters.remove(chapter.index)
-            } else {
-                downloadFailChapters[chapter.index] =
-                    (downloadFailChapters[chapter.index] ?: 0) + 1
-            }
-        } else {
-            delay(1000)
-            if (addLoading(index)) {
-                download(downloadScope, chapter, preDownloadSemaphore)
-            }
-        }
-    }
-
-    /**
-     * 获取正文
-     */
-    private suspend fun download(
-        scope: CoroutineScope,
-        chapter: BookChapter,
-        semaphore: Semaphore? = null,
-    ) {
-        val book = book ?: return removeLoading(chapter.index)
-        val bookSource = bookSource
-        if (bookSource != null) {
-            downloadNetworkContent(bookSource, scope, chapter, book, semaphore, success = { content ->
-                // 正文先展示;「已下载」集合等图片补全后再写入,避免预下载误跳过缺图章节
-                contentLoadFinish(chapter, content)
-                if (cacheChapterImagesIfNeeded(book, chapter, content)) {
-                    downloadedChapters.add(chapter.index)
-                    downloadFailChapters.remove(chapter.index)
-                } else {
-                    downloadFailChapters[chapter.index] =
-                        (downloadFailChapters[chapter.index] ?: 0) + 1
-                }
-            }, error = {
-                downloadFailChapters[chapter.index] =
-                    (downloadFailChapters[chapter.index] ?: 0) + 1
-                contentLoadFinish(chapter, null)
-            }, cancel = {
-                contentLoadFinish(chapter, null, canceled = true)
-            })
-        } else {
-            contentLoadFinish(chapter, null, "加载内容失败 没有书源")
-        }
-    }
-
-    @Synchronized
-    fun upToc() {
-        val bookSource = bookSource ?: return
-        val book = book ?: return
-        if (!book.canUpdate) return
-        if (System.currentTimeMillis() - book.lastCheckTime < 600000) return
-        book.lastCheckTime = System.currentTimeMillis()
-        WebBook.getChapterList(this, bookSource, book).onSuccess(IO) { cList ->
-            if (book.bookUrl == ReadManga.book?.bookUrl
-                && cList.size > chapterSize
-            ) {
-                appDb.bookChapterDao.delByBook(book.bookUrl)
-                appDb.bookChapterDao.insert(*cList.toTypedArray())
-                saveRead()
-                chapterSize = cList.size
-                simulatedChapterSize = book.simulatedTotalChapterNum()
-                nextMangaChapter ?: loadContent(durChapterIndex + 1)
-            }
-        }
-    }
-
-    fun uploadProgress(successAction: (() -> Unit)? = null) {
-        book?.let {
-            launch(IO) {
-                AppWebDav.uploadBookProgress(it) {
-                    successAction?.invoke()
-                }
-                ensureActive()
-                it.update()
-                successAction?.invoke()
-            }
-        }
-    }
-
-    /**
-     * 同步阅读进度
-     * 如果当前进度快于服务器进度或者没有进度进行上传,如果慢与服务器进度则执行传入动作
-     */
-    fun syncProgress(
-        newProgressAction: ((progress: BookProgress) -> Unit)? = null,
-        uploadSuccessAction: (() -> Unit)? = null,
-        syncSuccessAction: (() -> Unit)? = null,
-    ) {
-        if (!ReadConfig.syncBookProgress) return
-        val book = book ?: return
-        Coroutine.async {
-            AppWebDav.getBookProgress(book)
-        }.onError {
-            AppLog.put("拉取阅读进度失败", it)
-        }.onSuccess { progress ->
-            if (progress == null || progress.durChapterIndex < book.durChapterIndex ||
-                (progress.durChapterIndex == book.durChapterIndex
-                        && progress.durChapterPos < book.durChapterPos)
-            ) {
-                // 服务器没有进度或者进度比服务器快,上传现有进度
-                Coroutine.async {
-                    AppWebDav.uploadBookProgress(book, onSuccess = uploadSuccessAction)
-                    book.update()
-                }
-            } else if (progress.durChapterIndex > book.durChapterIndex ||
-                progress.durChapterPos > book.durChapterPos
-            ) {
-                // 进度比服务器慢,执行传入动作
-                newProgressAction?.invoke(progress)
-            } else {
-                syncSuccessAction?.invoke()
-            }
-        }
-    }
-
-    fun setProgress(progress: BookProgress) {
-        if (progress.durChapterIndex < chapterSize &&
-            (durChapterIndex != progress.durChapterIndex
-                    || durChapterPos != progress.durChapterPos)
-        ) {
-            mCallback?.showLoading()
-            if (progress.durChapterIndex == durChapterIndex) {
-                durChapterPos = progress.durChapterPos
-                mCallback?.upContent()
-            } else {
-                durChapterIndex = progress.durChapterIndex
-                durChapterPos = progress.durChapterPos
-                loadContent()
-            }
-            saveRead()
-        }
-    }
-
-    fun showLoading() {
-        mCallback?.showLoading()
-    }
-
-    fun onChapterListUpdated(newBook: Book) {
-        if (newBook.isSameNameAuthor(book)) {
-            book = newBook
-            chapterSize = newBook.totalChapterNum
-            simulatedChapterSize = newBook.simulatedTotalChapterNum()
-            if (simulatedChapterSize > 0 && durChapterIndex > simulatedChapterSize - 1) {
-                durChapterIndex = simulatedChapterSize - 1
-            }
-            if (mCallback == null) {
-                clearMangaChapter()
-            } else {
-                loadContent()
-            }
-        }
-    }
-
-    /**
-     * 注册回调
-     */
-    fun register(cb: Callback) {
-        mCallback = cb
-    }
-
-    /**
-     * 取消注册回调
-     */
-    fun unregister(cb: Callback) {
-        if (mCallback === cb) {
-            mCallback = null
-        }
-        preDownloadTask?.cancel()
-        preDownloadTask = null
-        downloadScope.coroutineContext.cancelChildren()
-        coroutineContext.cancelChildren()
-    }
-
-    private suspend fun getManageChapter(chapter: BookChapter, content: String): MangaChapter {
-        val list = BookHelp.flowImages(chapter, content)
-            .distinctUntilChanged().mapIndexed { index, src ->
-                MangaPage(
-                    chapterIndex = chapter.index,
-                    chapterSize = chapterSize,
-                    imageUrl = src,
-                    index = index,
-                    chapterName = chapter.title
-                )
-            }.toList()
-
-        val imageCount = list.size
-
-        list.forEach {
-            it.imageCount = imageCount
-        }
-
-        if (ReadMangaConfig.hideMangaTitle && imageCount > 0) {
-            return MangaChapter(chapter, list, imageCount)
-        }
-
-        val pages = mutableListOf<BaseMangaPage>()
-
-        if (imageCount == 0 && chapter.isVolume) {
-            pages.add(ReaderLoading(chapter.index, -1, chapter.title, true))
-        } else {
-            pages.add(ReaderLoading(chapter.index, -1, "下一章 ${chapter.title}"))
-            pages.addAll(list)
-            pages.add(ReaderLoading(chapter.index, imageCount, "已读完 ${chapter.title}"))
-        }
-
-        return MangaChapter(chapter, pages, imageCount)
-    }
-
-    interface Callback {
-        fun upContent()
-        fun loadFail(msg: String)
-        fun sureNewProgress(progress: BookProgress)
-        fun showLoading()
-        fun startLoad()
-    }
-}

+ 0 - 6
app/src/main/java/io/legado/app/model/manga/BaseMangaPage.kt

@@ -1,6 +0,0 @@
-package io.legado.app.model.manga
-
-interface BaseMangaPage {
-    val chapterIndex: Int
-    val index: Int
-}

+ 0 - 9
app/src/main/java/io/legado/app/model/manga/MangaChapter.kt

@@ -1,9 +0,0 @@
-package io.legado.app.model.manga
-
-import io.legado.app.data.entities.BookChapter
-
-data class MangaChapter(
-    val chapter: BookChapter,
-    val pages: List<BaseMangaPage>,
-    val imageCount: Int,
-)

+ 0 - 8
app/src/main/java/io/legado/app/model/manga/MangaContent.kt

@@ -1,8 +0,0 @@
-package io.legado.app.model.manga
-
-data class MangaContent(
-    val position: Int,
-    val items: List<BaseMangaPage>,
-    val currentFinished: Boolean,
-    val nextFinished: Boolean,
-)

+ 0 - 10
app/src/main/java/io/legado/app/model/manga/MangaPage.kt

@@ -1,10 +0,0 @@
-package io.legado.app.model.manga
-
-data class MangaPage(
-    override val chapterIndex: Int = 0,
-    val chapterSize: Int,
-    val imageUrl: String = "",
-    override val index: Int = 0,
-    var imageCount: Int = 0,
-    val chapterName: String = "",
-) : BaseMangaPage

+ 0 - 8
app/src/main/java/io/legado/app/model/manga/ReaderLoading.kt

@@ -1,8 +0,0 @@
-package io.legado.app.model.manga
-
-data class ReaderLoading(
-    override val chapterIndex: Int = 0,
-    override val index: Int = 0,
-    val message: String? = null,
-    val isVolume: Boolean = false,
-) : BaseMangaPage

+ 0 - 4
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -57,7 +57,6 @@ import io.legado.app.lib.webdav.ObjectNotFoundException
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.BookCover
 import io.legado.app.model.ReadBook
-import io.legado.app.model.ReadManga
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.localBook.LocalBook
@@ -622,9 +621,6 @@ class BookInfoViewModel(
                 if (ReadBook.book?.bookUrl == book.bookUrl) {
                     ReadBook.clearTextChapter()
                 }
-                if (ReadManga.book?.bookUrl == book.bookUrl) {
-                    ReadManga.clearMangaChapter()
-                }
             }.onSuccess {
                 showMessage(R.string.clear_cache_success)
             }.onError {

+ 49 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaPageSpread.kt

@@ -0,0 +1,49 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.runtime.Immutable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+
+@Immutable
+internal data class MangaPageSpread(
+    val key: String,
+    val itemIndices: ImmutableList<Int>,
+) {
+    operator fun contains(itemIndex: Int): Boolean = itemIndex in itemIndices
+}
+
+internal fun buildMangaSpreads(
+    items: List<MangaReaderItemUi>,
+    doublePage: Boolean,
+    aspectRatios: Map<String, Float> = emptyMap(),
+): List<MangaPageSpread> {
+    if (!doublePage) return items.indices.map { index -> items.singleSpread(index) }
+    val result = mutableListOf<MangaPageSpread>()
+    var index = 0
+    while (index < items.size) {
+        val first = items[index]
+        val second = items.getOrNull(index + 1)
+        val firstIsWide = first is MangaReaderItemUi.Page &&
+            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 &&
+            first.chapterIndex == second.chapterIndex && !firstIsWide && !secondIsWide
+        ) {
+            result += MangaPageSpread(
+                key = "spread:${first.key}|${second.key}",
+                itemIndices = listOf(index, index + 1).toImmutableList(),
+            )
+            index += 2
+        } else {
+            result += items.singleSpread(index)
+            index++
+        }
+    }
+    return result
+}
+
+private fun List<MangaReaderItemUi>.singleSpread(index: Int) = MangaPageSpread(
+    key = "spread:${get(index).key}",
+    itemIndices = listOf(index).toImmutableList(),
+)

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

@@ -16,6 +16,8 @@ data class MangaReaderUiState(
     val bookName: String = "",
     val bookAuthor: String = "",
     val bookUrl: String = "",
+    val coverUrl: String? = null,
+    val customCoverUrl: String? = null,
     val chapterName: String = "",
     val chapterUrl: String? = null,
     val sourceName: String = "",
@@ -29,6 +31,9 @@ data class MangaReaderUiState(
     val chapterIndex: Int = 0,
     val chapterCount: Int = 0,
     val isLoading: Boolean = true,
+    val isChapterLoading: Boolean = false,
+    val pendingChapterIndex: Int? = null,
+    val navigationId: Long = 0L,
     val errorMessage: MangaReaderText? = null,
     val pendingMessages: ImmutableList<MangaReaderMessage> = persistentListOf(),
     val menuVisible: Boolean = false,
@@ -50,6 +55,7 @@ sealed interface MangaReaderItemUi {
     data class Page(
         override val key: String,
         val imageUrl: String,
+        val bookUrl: String,
         val chapterIndex: Int,
         val chapterCount: Int,
         val pageIndex: Int,
@@ -61,14 +67,40 @@ sealed interface MangaReaderItemUi {
     data class ChapterEdge(
         override val key: String,
         val message: String,
+        val loading: Boolean = false,
+        val retryChapterIndex: Int? = null,
+    ) : MangaReaderItemUi
+
+    /** A deliberate boundary between chapters; it is never a page from another chapter. */
+    @Stable
+    data class ChapterTransition(
+        override val key: String,
+        val direction: MangaChapterTransitionDirection,
+        val targetChapterIndex: Int?,
+        val currentChapterName: String,
+        val targetChapterName: String?,
+        val targetStatus: MangaChapterTransitionStatus,
+        val statusMessage: String? = null,
+        val retryChapterIndex: Int? = null,
     ) : MangaReaderItemUi
 }
 
+@Stable
+enum class MangaChapterTransitionDirection { PREVIOUS, NEXT }
+
+@Stable
+enum class MangaChapterTransitionStatus { WAITING, LOADING, READY, FAILED, UNAVAILABLE }
+
 @Stable
 data class MangaReaderSettings(
     val scrollMode: Int = MangaScrollMode.WEBTOON,
     val sidePaddingPercent: Int = 0,
     val backgroundColor: Color = Color.Black,
+    val autoBackground: Boolean = false,
+    val pageScaleType: Int = 0,
+    val zoomStartPosition: Int = 0,
+    val widePageMode: Int = 0,
+    val doublePageMode: Int = 0,
     val disableScale: Boolean = true,
     val disableScrollAnimation: Boolean = false,
     val disableCrossFade: Boolean = false,
@@ -97,6 +129,14 @@ data class MangaReaderSettings(
     val hideProgress: Boolean = false,
     val hideProgressLabel: Boolean = false,
     val footerAlignment: Int = 0,
+    val menuTopBarLiquidGlass: Boolean = false,
+    val menuBottomBarLiquidGlass: Boolean = false,
+    val menuBottomBarFloating: Boolean = true,
+    val menuBottomBarBlur: Boolean = false,
+    val menuTopBarCompact: Boolean = false,
+    val menuColorSource: Int = 0,
+    val menuSeedColor: Color = Color(0xFF6750A4),
+    val menuPaletteStyle: String = "tonalSpot",
     val sourceOrigin: String? = null,
     val clickActions: ImmutableList<Int> = persistentListOf(-1, -1, 1, 2, 0, 1, 2, 1, 1),
 )
@@ -169,13 +209,19 @@ sealed interface MangaReaderIntent {
         val key: MangaReaderSettingKey,
         val value: Int,
     ) : 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 PageStep(val direction: Int) : MangaReaderIntent
     data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
     data class VisibleItemChanged(
         val itemIndex: Int,
+        val firstItemIndex: Int = itemIndex,
+        val lastItemIndex: Int = itemIndex,
         val currentChapterVisible: Boolean,
+        val navigationId: Long,
     ) : MangaReaderIntent
+    data class PagerScrollChanged(val inProgress: Boolean) : MangaReaderIntent
     data class LongPressPage(val imageUrl: String) : MangaReaderIntent
     data class MessageShown(val id: Long) : MangaReaderIntent
 }
@@ -186,7 +232,7 @@ sealed interface MangaReaderSheet {
     data object SourceActions : MangaReaderSheet
 }
 
-enum class MangaReaderSettingsCategory { READER, FOOTER, FILTER, CLICK_ACTIONS, AUTO_READ }
+enum class MangaReaderSettingsCategory { READER, FOOTER, MENU, FILTER, CLICK_ACTIONS, AUTO_READ }
 
 sealed interface MangaReaderDialog {
     data object AddToShelf : MangaReaderDialog
@@ -200,6 +246,11 @@ enum class MangaReaderSettingKey {
     BACKGROUND_RED,
     BACKGROUND_GREEN,
     BACKGROUND_BLUE,
+    AUTO_BACKGROUND,
+    PAGE_SCALE_TYPE,
+    ZOOM_START_POSITION,
+    WIDE_PAGE_MODE,
+    DOUBLE_PAGE_MODE,
     DISABLE_SCALE,
     DISABLE_SCROLL_ANIMATION,
     DISABLE_CROSS_FADE,
@@ -228,6 +279,13 @@ enum class MangaReaderSettingKey {
     HIDE_PROGRESS,
     HIDE_PROGRESS_LABEL,
     FOOTER_ALIGNMENT,
+    MENU_TOP_BAR_LIQUID_GLASS,
+    MENU_BOTTOM_BAR_LIQUID_GLASS,
+    MENU_BOTTOM_BAR_FLOATING,
+    MENU_BOTTOM_BAR_BLUR,
+    MENU_TOP_BAR_COMPACT,
+    MENU_COLOR_SOURCE,
+    MENU_SEED_COLOR,
 }
 
 sealed interface MangaReaderEffect {

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

@@ -11,10 +11,20 @@ internal fun mangaClickRegionIndex(
     return row * 3 + column
 }
 
+internal fun mangaClickActionAt(
+    clickActions: List<Int>,
+    x: Float,
+    y: Float,
+    width: Int,
+    height: Int,
+): Int = clickActions.getOrNull(mangaClickRegionIndex(x, y, width, height)) ?: 0
+
 internal fun nextMangaClickAction(action: Int): Int = when (action) {
     -1 -> 0
     0 -> 1
     1 -> 2
+    2 -> 3
+    3 -> 4
     else -> -1
 }
 
@@ -50,3 +60,14 @@ internal fun mangaChapterSwitchDecision(
 
     else -> MangaChapterSwitch.NONE
 }
+
+internal fun shouldForceMangaChapterPosition(
+    hasPages: Boolean,
+    isLoading: Boolean,
+    currentBookUrl: String,
+    targetBookUrl: String,
+    pendingExplicitChapterIndex: Int?,
+    targetChapterIndex: Int,
+): Boolean =
+    !hasPages || isLoading || currentBookUrl != targetBookUrl ||
+        pendingExplicitChapterIndex == targetChapterIndex

+ 466 - 70
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -1,24 +1,39 @@
 package io.legado.app.ui.book.manga
 
 import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.expandVertically
+import androidx.compose.animation.SizeTransform
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.only
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
@@ -32,31 +47,77 @@ import androidx.compose.material.icons.filled.TouchApp
 import androidx.compose.material.icons.filled.Tune
 import androidx.compose.material3.Button
 import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import com.kyant.backdrop.Backdrop
+import dev.chrisbanes.haze.HazeState
 import io.legado.app.R
+import io.legado.app.ui.book.read.ReadMenuSlider
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ProvideThemeOverride
+import io.legado.app.ui.theme.ThemeColorSpec
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.buildThemeOverrideState
 import io.legado.app.ui.widget.components.icon.AppIcons
-import io.legado.app.ui.widget.components.reader.DefaultReaderMenuBottomSurface
-import io.legado.app.ui.widget.components.reader.DefaultReaderMenuTopSurface
 import io.legado.app.ui.widget.components.reader.ReaderMenuAction
 import io.legado.app.ui.widget.components.reader.ReaderMenuAnimatedBottom
 import io.legado.app.ui.widget.components.reader.ReaderMenuAnimatedTop
 import io.legado.app.ui.widget.components.reader.ReaderMenuDismissLayer
-import io.legado.app.ui.widget.components.reader.ReaderMenuIconButton
-import io.legado.app.ui.widget.components.reader.ReaderMenuSlider
-import io.legado.app.ui.widget.components.reader.ReaderMenuToolRow
+import io.legado.app.ui.widget.components.reader.ReaderMenuEffect
+import io.legado.app.ui.widget.components.reader.ReaderMenuPlacement
+import io.legado.app.ui.widget.components.reader.ReaderMenuTintStyle
+import io.legado.app.ui.widget.components.reader.ReaderMenuVisualState
+import io.legado.app.ui.widget.components.reader.readerMenuHazeEffect
+import io.legado.app.ui.widget.components.reader.readerMenuLiquidGlass
+import io.legado.app.ui.widget.components.reader.readerMenuLiquidGlassAvailable
+import io.legado.app.ui.widget.components.reader.readerMenuSurfaceBrush
+import io.legado.app.ui.widget.components.text.AnimatedText
+import io.legado.app.ui.widget.components.text.AppText
+
+private val MangaMenuSurfaceColor: Color
+    @androidx.compose.runtime.Composable
+    @androidx.compose.runtime.ReadOnlyComposable
+    get() = LegadoTheme.colorScheme.surfaceContainerHigh
+private val MangaMenuButtonColor: Color
+    @androidx.compose.runtime.Composable
+    @androidx.compose.runtime.ReadOnlyComposable
+    get() = LegadoTheme.colorScheme.surfaceContainerLow
+
+// 保留液态玻璃的模糊取样,同时用较高表面覆盖避免高对比度漫画页清晰穿透。
+private const val MangaMenuGlassAlpha = 0.7f
+private val MangaFooterContentColor = Color.White
+private val MangaFooterTextShadow = Shadow(
+    color = Color.Black.copy(alpha = 0.78f),
+    offset = Offset(1.5f, 1.5f),
+    blurRadius = 3f,
+)
 
 @Composable
 internal fun BoxScope.MangaFooter(state: MangaReaderUiState) {
-    val page = state.pages.getOrNull(state.currentItemIndex) as? MangaReaderItemUi.Page ?: return
     val settings = state.settings
     if (settings.hideFooter) return
+    val alignment = when (settings.footerAlignment) {
+        1 -> Alignment.BottomCenter
+        2 -> Alignment.BottomEnd
+        else -> Alignment.BottomStart
+    }
+    val page = state.pages.getOrNull(state.currentItemIndex) as? MangaReaderItemUi.Page ?: return
     val progress = if (page.chapterCount <= 0 || page.pageCount <= 0) 0.0 else {
         (page.chapterIndex.toDouble() + (page.pageIndex + 1.0) / page.pageCount) / page.chapterCount
     }
@@ -78,19 +139,14 @@ internal fun BoxScope.MangaFooter(state: MangaReaderUiState) {
             append("%.1f%%".format((progress * 100).coerceAtMost(100.0)))
         }
     }.trim()
-    val alignment = when (settings.footerAlignment) {
-        1 -> Alignment.BottomCenter
-        2 -> Alignment.BottomEnd
-        else -> Alignment.BottomStart
-    }
-    Text(
+    AnimatedText(
         text = text,
-        color = LegadoTheme.colorScheme.onSurface.copy(alpha = 0.78f),
-        style = LegadoTheme.typography.labelSmall,
+        color = MangaFooterContentColor,
+        style = LegadoTheme.typography.labelSmall.copy(shadow = MangaFooterTextShadow),
         modifier = Modifier
             .align(alignment)
             .navigationBarsPadding()
-            .padding(horizontal = 12.dp, vertical = 8.dp),
+            .padding(all = 16.dp),
     )
 }
 
@@ -98,8 +154,36 @@ internal fun BoxScope.MangaFooter(state: MangaReaderUiState) {
 internal fun BoxScope.MangaReaderMenu(
     state: MangaReaderUiState,
     onIntent: (MangaReaderIntent) -> Unit,
+    backdrop: Backdrop? = null,
+    hazeState: HazeState? = null,
+    menuSeedColor: Color? = null,
 ) {
     val readingPageDescription = stringResource(R.string.manga_reader_page_semantics)
+    val isDark = LegadoTheme.isDark
+    val menuTheme = remember(menuSeedColor, state.settings.menuPaletteStyle, isDark) {
+        menuSeedColor?.let { seedColor ->
+            buildThemeOverrideState(
+                seedColor = seedColor,
+                isDark = isDark,
+                paletteStyle = ThemeResolver.resolvePaletteStyle(state.settings.menuPaletteStyle),
+                colorSpec = ThemeColorSpec.SPEC_2021,
+                usePureBlack = false,
+            )
+        }
+    }
+    ProvideThemeOverride(menuTheme) {
+        MangaReaderMenuContent(state, onIntent, backdrop, hazeState, readingPageDescription)
+    }
+}
+
+@Composable
+private fun BoxScope.MangaReaderMenuContent(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    backdrop: Backdrop?,
+    hazeState: HazeState?,
+    readingPageDescription: String,
+) {
     ReaderMenuDismissLayer(
         visible = state.menuVisible,
         onDismiss = { onIntent(MangaReaderIntent.HideMenu) },
@@ -107,56 +191,262 @@ internal fun BoxScope.MangaReaderMenu(
     ReaderMenuAnimatedTop(
         visible = state.menuVisible,
     ) {
-        DefaultReaderMenuTopSurface {
-            Row(
-                modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(horizontal = 16.dp, vertical = 4.dp),
-                verticalAlignment = Alignment.CenterVertically,
-                horizontalArrangement = Arrangement.spacedBy(6.dp),
-            ) {
-                ReaderMenuIconButton(
-                    icon = AppIcons.Back,
-                    description = stringResource(R.string.back),
-                    onClick = { onIntent(MangaReaderIntent.BackPressed) },
+        MangaMenuTopBar(state, onIntent, backdrop)
+    }
+    ReaderMenuAnimatedBottom(
+        visible = state.menuVisible,
+    ) {
+        MangaMenuBottomBar(state, onIntent, backdrop, readingPageDescription, hazeState)
+    }
+}
+
+// ========== Top Bar ==========
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun MangaMenuTopBar(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    backdrop: Backdrop?,
+) {
+    val topBarGlass = state.settings.menuTopBarLiquidGlass && readerMenuLiquidGlassAvailable(backdrop)
+    val compact = state.settings.menuTopBarCompact
+    // 顶栏始终透明无表面背景,只显示胶囊与按钮(悬浮于漫画内容上)
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .windowInsetsPadding(
+                WindowInsets.safeDrawing.only(
+                    WindowInsetsSides.Top
                 )
-                Column(modifier = Modifier.weight(1f)) {
-                    Text(
-                        state.bookName,
-                        maxLines = 1,
-                        style = LegadoTheme.typography.titleMedium,
-                        modifier = Modifier.combinedClickable(
-                            onClick = { onIntent(MangaReaderIntent.OpenBookInfo) },
-                        ),
-                    )
-                    Text(
-                        state.chapterName,
-                        maxLines = 1,
-                        style = LegadoTheme.typography.labelMedium,
-                        modifier = Modifier.combinedClickable(
-                            onClick = { onIntent(MangaReaderIntent.OpenChapterUrl) },
+            )
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(16.dp),
+        ) {
+            MangaMenuIconButton(
+                icon = AppIcons.Back,
+                description = stringResource(R.string.back),
+                glassEnabled = topBarGlass,
+                backdrop = backdrop,
+                onClick = { onIntent(MangaReaderIntent.BackPressed) },
+            )
+            MangaTitleCapsule(state, onIntent, glassEnabled = topBarGlass, backdrop = backdrop)
+            if (!compact) {
+                MangaMenuIconButton(
+                    Icons.Filled.SwapHoriz,
+                    stringResource(R.string.change_origin),
+                    glassEnabled = topBarGlass,
+                    backdrop = backdrop,
+                ) { onIntent(MangaReaderIntent.ChangeSource) }
+            }
+            if (!compact) {
+                MangaMenuIconButton(
+                    Icons.Filled.Refresh,
+                    stringResource(R.string.refresh),
+                    glassEnabled = topBarGlass,
+                    backdrop = backdrop,
+                ) { onIntent(MangaReaderIntent.RefreshChapter) }
+            }
+            MangaMenuIconButton(
+                Icons.Filled.MoreVert,
+                stringResource(R.string.more_actions),
+                glassEnabled = topBarGlass,
+                backdrop = backdrop,
+            ) {
+                onIntent(MangaReaderIntent.OpenSourceActions)
+            }
+        }
+    }
+}
+
+@Composable
+private fun RowScope.MangaTitleCapsule(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    glassEnabled: Boolean = false,
+    backdrop: Backdrop? = null,
+) {
+    val pillShape = RoundedCornerShape(50)
+    Box(
+        modifier = Modifier
+            .weight(1f)
+            .height(40.dp)
+            .then(
+                if (glassEnabled) {
+                    Modifier.readerMenuLiquidGlass(
+                        backdrop = backdrop,
+                        shape = pillShape,
+                        surfaceBrush = readerMenuSurfaceBrush(
+                            style = ReaderMenuTintStyle.Fill,
+                            placement = ReaderMenuPlacement.Top,
+                            color = MangaMenuSurfaceColor,
+                            alpha = MangaMenuGlassAlpha,
                         ),
+                        blurRadius = 32.dp,
+                        lensRadius = 24.dp,
+                        useLens = false,
                     )
+                } else {
+                    Modifier
+                        .clip(pillShape)
+                        .background(MangaMenuButtonColor, pillShape)
                 }
-                ReaderMenuIconButton(Icons.Filled.SwapHoriz, stringResource(R.string.change_origin)) {
-                    onIntent(MangaReaderIntent.ChangeSource)
-                }
-                ReaderMenuIconButton(Icons.Filled.Refresh, stringResource(R.string.refresh)) {
-                    onIntent(MangaReaderIntent.RefreshChapter)
-                }
-                ReaderMenuIconButton(Icons.Filled.MoreVert, stringResource(R.string.book_source)) {
-                    onIntent(MangaReaderIntent.OpenSourceActions)
-                }
+            )
+            .clickable(
+                indication = if (glassEnabled) null else LocalIndication.current,
+                interactionSource = remember { MutableInteractionSource() },
+                onClick = { onIntent(MangaReaderIntent.OpenBookInfo) },
+            )
+            .padding(horizontal = 12.dp),
+        contentAlignment = Alignment.CenterStart,
+    ) {
+        Column {
+            Text(
+                state.bookName,
+                style = LegadoTheme.typography.labelMediumEmphasized,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+            if (state.chapterName.isNotBlank()) {
+                Text(
+                    state.chapterName,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
             }
         }
     }
-    ReaderMenuAnimatedBottom(
-        visible = state.menuVisible,
+}
+
+// ========== Bottom Bar ==========
+
+@Composable
+private fun MangaMenuBottomBar(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    backdrop: Backdrop?,
+    readingPageDescription: String,
+    hazeState: HazeState? = null,
+) {
+    // 悬浮底栏玻璃化表面;贴边底栏只玻璃化操作控件,和文章阅读菜单保持一致。
+    val glass = state.settings.menuBottomBarLiquidGlass && readerMenuLiquidGlassAvailable(backdrop)
+    val floating = state.settings.menuBottomBarFloating
+    val configOpen = state.settingsCategory != null
+    // 文章阅读菜单在展开时禁用渐进样式,并以实心毛玻璃承载设置内容。
+    val blur = (state.settings.menuBottomBarBlur || (!floating && glass && configOpen)) &&
+            hazeState != null
+    val glassOnBar = floating && glass
+    val glassOnControls = !floating && glass
+    // 悬浮展开用实心(不透明),非悬浮展开降低表面不透明度露出背后漫画内容
+    val surfaceAlpha = when {
+        floating && configOpen -> 85
+        configOpen -> 55
+        else -> 75
+    }
+    val shape = if (floating) {
+        RoundedCornerShape(32.dp)
+    } else {
+        RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
+    }
+    val bottomBarVisualState = ReaderMenuVisualState(
+        effect = ReaderMenuEffect.Haze,
+        tintStyle = if (floating || configOpen) {
+            ReaderMenuTintStyle.Fill
+        } else {
+            ReaderMenuTintStyle.Gradient
+        },
+        styleEnabled = !configOpen,
+        tintAllowed = true,
+        tintFill = true,
+    )
+
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .then(
+                if (floating) {
+                    Modifier
+                        .navigationBarsPadding()
+                        .padding(horizontal = 16.dp, vertical = 16.dp)
+                } else {
+                    Modifier
+                }
+            )
+            .then(
+                when {
+                    // 液态玻璃只用于悬浮底栏表面
+                    glassOnBar -> Modifier.readerMenuLiquidGlass(
+                        backdrop = backdrop,
+                        shape = shape,
+                        surfaceBrush = readerMenuSurfaceBrush(
+                            style = ReaderMenuTintStyle.Fill,
+                            placement = ReaderMenuPlacement.Bottom,
+                            color = MangaMenuSurfaceColor,
+                            alpha = MangaMenuGlassAlpha,
+                        ),
+                        blurRadius = 24.dp,
+                        lensRadius = 24.dp,
+                        useLens = true,
+                    )
+
+                    // 模糊开关开启时用毛玻璃,非悬浮时渐进渐变;先按形状裁剪保留圆角
+                    blur -> Modifier
+                        .clip(shape)
+                        .readerMenuHazeEffect(
+                            state = hazeState,
+                            visualState = bottomBarVisualState,
+                            placement = ReaderMenuPlacement.Bottom,
+                            baseColor = MangaMenuSurfaceColor,
+                            tintColor = null,
+                            blurRadius = 32,
+                            surfaceAlpha = surfaceAlpha,
+                        )
+
+                    // 非悬浮底栏使用自下而上的渐变背景
+                    !floating -> Modifier
+                        .clip(shape)
+                        .background(
+                            readerMenuSurfaceBrush(
+                                style = ReaderMenuTintStyle.Gradient,
+                                placement = ReaderMenuPlacement.Bottom,
+                                color = MangaMenuSurfaceColor,
+                                alpha = surfaceAlpha / 100f,
+                            )
+                        )
+
+                    else -> Modifier
+                }
+            ),
+        shape = shape,
+        color = when {
+            glassOnBar || blur -> Color.Transparent
+            floating -> MangaMenuSurfaceColor
+            else -> Color.Transparent
+        },
+        border = if (!glassOnBar && !blur && floating) {
+            BorderStroke(1.dp, LegadoTheme.colorScheme.outlineVariant)
+        } else {
+            null
+        },
     ) {
-        DefaultReaderMenuBottomSurface {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .then(if (!floating) Modifier.navigationBarsPadding() else Modifier),
+        ) {
             AnimatedContent(
                 targetState = state.settingsCategory != null,
                 transitionSpec = {
-                    (expandVertically(expandFrom = Alignment.Bottom) + fadeIn())
-                        .togetherWith(shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut())
+                    (slideInVertically { it / 4 } + fadeIn())
+                        .togetherWith(slideOutVertically { -it / 4 } + fadeOut())
+                        .using(SizeTransform(clip = true))
                 },
                 label = "mangaMenuExpand",
             ) { isSettings ->
@@ -167,17 +457,19 @@ internal fun BoxScope.MangaReaderMenu(
                         if (state.pageCount > 1) {
                             Row(
                                 modifier = Modifier.fillMaxWidth()
-                                    .padding(horizontal = 16.dp, vertical = 4.dp),
+                                    .padding(horizontal = 16.dp),
                                 verticalAlignment = Alignment.CenterVertically,
-                                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                                horizontalArrangement = Arrangement.spacedBy(16.dp),
                             ) {
-                                ReaderMenuIconButton(
+                                MangaMenuIconButton(
                                     Icons.AutoMirrored.Filled.ArrowBack,
-                                    stringResource(R.string.previous_chapter)
+                                    stringResource(R.string.previous_chapter),
+                                    glassEnabled = glassOnControls,
+                                    backdrop = backdrop,
                                 ) {
                                     onIntent(MangaReaderIntent.PreviousChapter)
                                 }
-                                ReaderMenuSlider(
+                                ReadMenuSlider(
                                     value = state.currentPage.toFloat()
                                         .coerceIn(0f, (state.pageCount - 1).toFloat()),
                                     onValueChange = { onIntent(MangaReaderIntent.SeekToPage(it.toInt())) },
@@ -185,17 +477,21 @@ internal fun BoxScope.MangaReaderMenu(
                                     steps = (state.pageCount - 2).coerceAtLeast(0),
                                     accessibilityLabel = readingPageDescription,
                                     modifier = Modifier.weight(1f),
+                                    backdrop = backdrop,
+                                    glassThumbEnabled = glassOnControls,
                                 )
-                                ReaderMenuIconButton(
+                                MangaMenuIconButton(
                                     Icons.AutoMirrored.Filled.ArrowForward,
-                                    stringResource(R.string.next_chapter)
+                                    stringResource(R.string.next_chapter),
+                                    glassEnabled = glassOnControls,
+                                    backdrop = backdrop,
                                 ) {
                                     onIntent(MangaReaderIntent.NextChapter)
                                 }
                             }
                         }
                         Spacer(Modifier.height(12.dp))
-                        ReaderMenuToolRow(
+                        MangaMenuToolRow(
                             listOf(
                                 ReaderMenuAction(
                                     Icons.AutoMirrored.Filled.MenuBook,
@@ -205,9 +501,11 @@ internal fun BoxScope.MangaReaderMenu(
                                 },
                                 ReaderMenuAction(
                                     icon = Icons.Filled.AutoMode,
-                                    description = if (state.autoReadEnabled) stringResource(R.string.stop) else stringResource(
-                                        R.string.manga_reader_auto_short
-                                    ),
+                                    description = if (state.autoReadEnabled) {
+                                        stringResource(R.string.stop)
+                                    } else {
+                                        stringResource(R.string.manga_reader_auto_short)
+                                    },
                                     onClick = { onIntent(MangaReaderIntent.ToggleAutoRead) },
                                     onLongClick = {
                                         onIntent(
@@ -247,7 +545,9 @@ internal fun BoxScope.MangaReaderMenu(
                                         )
                                     )
                                 },
-                            )
+                            ),
+                            glassEnabled = glassOnControls,
+                            backdrop = backdrop,
                         )
                     }
                 }
@@ -256,6 +556,102 @@ internal fun BoxScope.MangaReaderMenu(
     }
 }
 
+@Composable
+private fun MangaMenuToolRow(
+    actions: List<ReaderMenuAction>,
+    glassEnabled: Boolean,
+    backdrop: Backdrop?,
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp),
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        actions.forEach { action ->
+            MangaMenuIconButton(
+                icon = action.icon,
+                description = action.description,
+                glassEnabled = glassEnabled,
+                backdrop = backdrop,
+                onClick = action.onClick,
+                onLongClick = action.onLongClick,
+            )
+        }
+        repeat((5 - actions.size).coerceAtLeast(0)) {
+            Spacer(Modifier.size(40.dp))
+        }
+    }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun MangaMenuIconButton(
+    icon: ImageVector,
+    description: String,
+    modifier: Modifier = Modifier,
+    onLongClick: (() -> Unit)? = null,
+    glassEnabled: Boolean = false,
+    backdrop: Backdrop? = null,
+    onClick: () -> Unit,
+) {
+    val tint = LegadoTheme.colorScheme.onSurfaceVariant
+    val interactionSource = remember { MutableInteractionSource() }
+    Box(
+        modifier = modifier
+            .size(40.dp)
+            .then(
+                if (glassEnabled) {
+                    Modifier.readerMenuLiquidGlass(
+                        backdrop = backdrop,
+                        shape = CircleShape,
+                        surfaceBrush = readerMenuSurfaceBrush(
+                            style = ReaderMenuTintStyle.Fill,
+                            placement = ReaderMenuPlacement.Top,
+                            color = MangaMenuSurfaceColor,
+                            alpha = MangaMenuGlassAlpha,
+                        ),
+                        blurRadius = 32.dp,
+                        lensRadius = 24.dp,
+                        useLens = true,
+                        interactive = true,
+                    )
+                } else {
+                    Modifier
+                        .clip(CircleShape)
+                        .background(MangaMenuButtonColor, CircleShape)
+                }
+            )
+            .then(
+                if (onLongClick != null) {
+                    Modifier.combinedClickable(
+                        indication = if (glassEnabled) null else LocalIndication.current,
+                        interactionSource = interactionSource,
+                        role = Role.Button,
+                        onClick = onClick,
+                        onLongClick = onLongClick,
+                    )
+                } else {
+                    Modifier.clickable(
+                        indication = if (glassEnabled) null else LocalIndication.current,
+                        interactionSource = interactionSource,
+                        role = Role.Button,
+                        onClick = onClick,
+                    )
+                }
+            )
+            .semantics { contentDescription = description },
+        contentAlignment = Alignment.Center,
+    ) {
+        Icon(
+            imageVector = icon,
+            contentDescription = null,
+            tint = tint,
+            modifier = Modifier.size(20.dp),
+        )
+    }
+}
+
 @Composable
 internal fun BoxScope.ReaderStatusOverlay(
     state: MangaReaderUiState,
@@ -282,7 +678,7 @@ internal fun BoxScope.ReaderStatusOverlay(
                 horizontalAlignment = Alignment.CenterHorizontally,
                 verticalArrangement = Arrangement.Center,
             ) {
-                Text(errorText, modifier = Modifier.padding(24.dp))
+                AppText(errorText, modifier = Modifier.padding(24.dp))
                 Button(onClick = { onIntent(MangaReaderIntent.Retry) }) { Text(stringResource(R.string.retry)) }
             }
         }

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

@@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
+import dev.chrisbanes.haze.HazeState
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -160,12 +161,22 @@ fun MangaReaderRouteScreen(
         }
     }
 
-    MangaReaderScreen(state = state, onIntent = viewModel::onIntent)
+    val menuHazeState = remember { HazeState() }
+    val useMenuHaze = state.settings.menuBottomBarBlur ||
+            (!state.settings.menuBottomBarFloating &&
+                    state.settings.menuBottomBarLiquidGlass &&
+                    state.settingsCategory != null)
+    MangaReaderScreen(
+        state = state,
+        onIntent = viewModel::onIntent,
+        hazeState = if (useMenuHaze) menuHazeState else null,
+    )
     if (state.activeSheet == MangaReaderSheet.Catalog && state.bookUrl.isNotEmpty()) {
         ReaderBookSheetRoute(
             show = true,
             bookUrl = state.bookUrl,
             initialTab = ReaderBookSheetTab.Toc,
+            currentChapterIndex = state.pendingChapterIndex ?: state.chapterIndex,
             onDismissRequest = { viewModel.onIntent(MangaReaderIntent.DismissSheet) },
             onChapterClick = { chapterIndex, pageIndex ->
                 viewModel.onIntent(MangaReaderIntent.DismissSheet)

Разница между файлами не показана из-за своего большого размера
+ 808 - 178
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt


+ 68 - 35
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt

@@ -1,20 +1,36 @@
 package io.legado.app.ui.book.manga
 
 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.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+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.Edit
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.ShoppingCart
+import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.ui.widget.components.card.SettingCard
+import io.legado.app.ui.book.read.sheet.ReaderBookHeader
+import io.legado.app.ui.book.read.sheet.ReaderBookHeaderState
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.reader.ReaderMenuActionSquare
+
+private data class MangaMoreAction(
+    val label: String,
+    val icon: ImageVector,
+    val intent: MangaReaderIntent,
+)
 
 @Composable
 internal fun MangaReaderSourceActionsSheet(
@@ -24,39 +40,56 @@ internal fun MangaReaderSourceActionsSheet(
     AppModalBottomSheet(
         show = true,
         onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
-        title = stringResource(R.string.manga_reader_source_actions),
+        contentPaddingEnabled = false,
     ) {
-        Column(
-            Modifier
-                .fillMaxWidth()
-                .verticalScroll(rememberScrollState()),
-            verticalArrangement = Arrangement.spacedBy(8.dp),
-        ) {
-            SettingCard(
-                modifier = Modifier.fillMaxWidth(),
-                cornerRadius = 16.dp,
-            ) {
-                ClickableSettingItem(
-                    title = stringResource(R.string.login),
-                    onClick = { onIntent(MangaReaderIntent.OpenSourceLogin) },
-                )
-                ClickableSettingItem(
-                    title = stringResource(R.string.manga_reader_buy_chapter),
-                    onClick = { onIntent(MangaReaderIntent.RequestPayCurrentChapter) },
-                )
-                ClickableSettingItem(
-                    title = stringResource(R.string.edit_source),
-                    onClick = { onIntent(MangaReaderIntent.OpenSourceEdit) },
-                )
-                ClickableSettingItem(
-                    title = stringResource(R.string.disable_source),
-                    onClick = {
-                        onIntent(MangaReaderIntent.DisableCurrentSource)
-                        onIntent(MangaReaderIntent.DismissSheet)
-                    },
-                )
+        fun dispatch(intent: MangaReaderIntent) {
+            onIntent(MangaReaderIntent.DismissSheet)
+            onIntent(intent)
+        }
+        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))
+        val actions = listOf(
+            MangaMoreAction(stringResource(R.string.change_origin), Icons.Default.SwapHoriz, MangaReaderIntent.ChangeSource),
+            MangaMoreAction(stringResource(R.string.refresh), Icons.Default.Refresh, MangaReaderIntent.RefreshChapter),
+            MangaMoreAction(stringResource(R.string.login), Icons.AutoMirrored.Filled.Login, MangaReaderIntent.OpenSourceLogin),
+            MangaMoreAction(stringResource(R.string.manga_reader_buy_chapter), Icons.Default.ShoppingCart, MangaReaderIntent.RequestPayCurrentChapter),
+            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)) {
+            actions.chunked(4).forEach { rowActions ->
+                Row(
+                    modifier = Modifier.fillMaxWidth(),
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                ) {
+                    rowActions.forEach { action ->
+                        ReaderMenuActionSquare(
+                            icon = action.icon,
+                            text = action.label,
+                            modifier = Modifier.weight(1f),
+                            onClick = { dispatch(action.intent) },
+                        )
+                    }
+                    repeat(4 - rowActions.size) { Spacer(Modifier.weight(1f)) }
+                }
+                Spacer(Modifier.height(8.dp))
             }
-            Spacer(Modifier.height(24.dp))
         }
+        Spacer(Modifier.height(8.dp))
     }
 }

Разница между файлами не показана из-за своего большого размера
+ 468 - 252
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt


+ 283 - 35
app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt

@@ -11,6 +11,8 @@ 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
@@ -23,28 +25,34 @@ import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+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.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 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
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.icon.AppIcons
 import io.legado.app.ui.widget.components.pager.pagerHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
-import io.legado.app.ui.widget.components.reader.ReaderMenuIconButton
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.tabRow.CardTabRow
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
 import kotlin.math.roundToInt
 
 private val MangaSettingsPanelContentMaxHeight = 380.dp
@@ -53,6 +61,7 @@ private val MangaSettingsPanelContentMaxHeight = 380.dp
 private val MangaSettingsTabs = listOf(
     MangaReaderSettingsCategory.READER,
     MangaReaderSettingsCategory.FOOTER,
+    MangaReaderSettingsCategory.MENU,
     MangaReaderSettingsCategory.FILTER,
     MangaReaderSettingsCategory.CLICK_ACTIONS,
 )
@@ -61,12 +70,13 @@ private val MangaReaderSettingsCategory.titleRes: Int
     get() = when (this) {
         MangaReaderSettingsCategory.READER -> R.string.general
         MangaReaderSettingsCategory.FOOTER -> R.string.footer
+        MangaReaderSettingsCategory.MENU -> R.string.manga_reader_menu_layout
         MangaReaderSettingsCategory.FILTER -> R.string.manga_reader_filter_short
         MangaReaderSettingsCategory.CLICK_ACTIONS -> R.string.manga_reader_click_area_short
         MangaReaderSettingsCategory.AUTO_READ -> R.string.manga_reader_auto_read
     }
 
-private enum class MangaColorPickerTarget { BACKGROUND, FILTER }
+private enum class MangaColorPickerTarget { BACKGROUND, FILTER, MENU_SEED }
 
 /**
  * 漫画阅读设置的底部栏面板:顶部分类 tab([CardTabRow]),内容区用
@@ -77,13 +87,19 @@ internal fun MangaSettingsPanel(
     state: MangaReaderUiState,
     onIntent: (MangaReaderIntent) -> Unit,
 ) {
-    val categories = MangaReaderSettingsCategory.entries
     val current = state.settingsCategory ?: MangaReaderSettingsCategory.READER
+    if (current == MangaReaderSettingsCategory.AUTO_READ) {
+        AutoReadSettingsPage(state, onIntent)
+        return
+    }
+    val scope = rememberCoroutineScope()
+    val tabIndex = MangaSettingsTabs.indexOf(current).coerceAtLeast(0)
     val pagerState = rememberPagerState(
-        initialPage = current.ordinal,
-        pageCount = { categories.size },
+        initialPage = tabIndex,
+        pageCount = { MangaSettingsTabs.size },
     )
-    var selectedTab by remember { mutableIntStateOf(current.ordinal) }
+    var selectedTab by remember { mutableIntStateOf(tabIndex) }
+    var clickScrollCount by remember { mutableIntStateOf(0) }
     var colorPickerTarget by remember { mutableStateOf<MangaColorPickerTarget?>(null) }
     val pageHeights = remember { mutableStateMapOf<Int, Int>() }
     val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
@@ -91,32 +107,52 @@ internal fun MangaSettingsPanel(
     LaunchedEffect(pagerState) {
         snapshotFlow { pagerState.currentPage }
             .distinctUntilChanged()
-            .collect { selectedTab = it }
+            .collect { page ->
+                if (clickScrollCount == 0) selectedTab = page
+            }
     }
-    LaunchedEffect(current) {
-        if (pagerState.currentPage != current.ordinal) {
-            pagerState.scrollToPage(current.ordinal)
+    LaunchedEffect(tabIndex) {
+        if (pagerState.currentPage != tabIndex) {
+            pagerState.scrollToPage(tabIndex)
         }
     }
 
-    Column(modifier = Modifier.fillMaxWidth()) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(top = 16.dp)
+    ) {
         Row(
             modifier = Modifier
                 .fillMaxWidth()
-                .padding(horizontal = 8.dp, vertical = 4.dp),
+                .padding(horizontal = 16.dp),
             verticalAlignment = Alignment.CenterVertically,
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
         ) {
-            ReaderMenuIconButton(
-                icon = AppIcons.Back,
-                description = stringResource(R.string.back),
+            MediumTonalButton(
                 onClick = { onIntent(MangaReaderIntent.CloseSettings) },
+                icon = AppIcons.Back,
+                contentDescription = stringResource(R.string.back),
             )
             CardTabRow(
                 tabTitles = MangaSettingsTabs.map { stringResource(it.titleRes) },
-                selectedTabIndex = MangaSettingsTabs.indexOf(categories[selectedTab]),
+                selectedTabIndex = selectedTab,
                 onTabSelected = { index ->
-                    onIntent(MangaReaderIntent.OpenSettings(MangaSettingsTabs[index]))
+                    selectedTab = index
+                    clickScrollCount++
+                    scope.launch {
+                        try {
+                            pagerState.animateScrollToPage(
+                                page = index,
+                                animationSpec = tween(
+                                    durationMillis = 300,
+                                    easing = FastOutSlowInEasing,
+                                ),
+                            )
+                        } finally {
+                            clickScrollCount = (clickScrollCount - 1).coerceAtLeast(0)
+                        }
+                    }
                 },
                 modifier = Modifier.weight(1f),
             )
@@ -124,9 +160,12 @@ internal fun MangaSettingsPanel(
         Spacer(Modifier.height(8.dp))
         HorizontalPager(
             state = pagerState,
+            // 顶部对齐 + 裁剪,对齐阅读正文 SystemMenuPage:切换不同高度页面时内容顶部不动、只收放底部
+            verticalAlignment = Alignment.Top,
             modifier = Modifier
                 .fillMaxWidth()
                 .heightIn(max = MangaSettingsPanelContentMaxHeight)
+                .clipToBounds()
                 .pagerHeight(animatedHeight),
         ) { page ->
             Box(
@@ -137,9 +176,10 @@ internal fun MangaSettingsPanel(
                 Column(
                     modifier = Modifier
                         .fillMaxWidth()
+                        .padding(horizontal = 16.dp)
                         .verticalScroll(rememberScrollState()),
                 ) {
-                    when (categories[page]) {
+                    when (MangaSettingsTabs[page]) {
                         MangaReaderSettingsCategory.READER -> ReaderSettingsContent(
                             state.settings,
                             onIntent
@@ -150,6 +190,12 @@ internal fun MangaSettingsPanel(
                             onIntent
                         )
 
+                        MangaReaderSettingsCategory.MENU -> MenuSettingsContent(
+                            settings = state.settings,
+                            onPickSeedColor = { colorPickerTarget = MangaColorPickerTarget.MENU_SEED },
+                            onIntent = onIntent,
+                        )
+
                         MangaReaderSettingsCategory.FILTER -> FilterSettingsContent(
                             settings = state.settings,
                             onPickBackground = {
@@ -164,10 +210,8 @@ internal fun MangaSettingsPanel(
                             onIntent
                         )
 
-                        MangaReaderSettingsCategory.AUTO_READ -> AutoReadSettingsContent(
-                            state,
-                            onIntent
-                        )
+                        // 自动阅读不走 pager,已在 MangaSettingsPanel 入口单独处理
+                        MangaReaderSettingsCategory.AUTO_READ -> Unit
                     }
                 }
             }
@@ -184,6 +228,7 @@ internal fun MangaSettingsPanel(
                     green = 255 - state.settings.filterGreen,
                     blue = 255 - state.settings.filterBlue,
                 ).toArgb()
+                MangaColorPickerTarget.MENU_SEED -> state.settings.menuSeedColor.toArgb()
             },
             onDismissRequest = { colorPickerTarget = null },
             onColorSelected = { argb ->
@@ -234,6 +279,10 @@ internal fun MangaSettingsPanel(
                             )
                         )
                     }
+
+                    MangaColorPickerTarget.MENU_SEED -> onIntent(
+                        MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.MENU_SEED_COLOR, argb)
+                    )
                 }
                 colorPickerTarget = null
             },
@@ -241,11 +290,55 @@ internal fun MangaSettingsPanel(
     }
 }
 
+/** 自动阅读配置页:返回 + 标题头,不显示分类 tab(对齐阅读正文 [io.legado.app.ui.book.read.ReadBookMenuBar] 的配置页头)。 */
+@Composable
+private fun AutoReadSettingsPage(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(vertical = 12.dp),
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            MediumTonalButton(
+                onClick = { onIntent(MangaReaderIntent.CloseSettings) },
+                icon = AppIcons.Back,
+                contentDescription = stringResource(R.string.back),
+            )
+            Text(
+                text = stringResource(MangaReaderSettingsCategory.AUTO_READ.titleRes),
+                style = LegadoTheme.typography.titleMedium,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+            )
+        }
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+        ) {
+            AutoReadSettingsContent(state, onIntent)
+        }
+    }
+}
+
 @Composable
 private fun ReaderSettingsContent(
     settings: MangaReaderSettings,
     onIntent: (MangaReaderIntent) -> Unit,
 ) {
+    val isWebtoon = settings.scrollMode == MangaScrollMode.WEBTOON ||
+        settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
+    val isHorizontalPaged = settings.scrollMode == MangaScrollMode.PAGE_LEFT_TO_RIGHT ||
+        settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
     TinyDropdownSettingItem(
         title = stringResource(R.string.read_type),
         selectedValue = settings.scrollMode.toString(),
@@ -267,12 +360,67 @@ private fun ReaderSettingsContent(
             onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SCROLL_MODE, it.toInt()))
         },
     )
-    SettingSlider(
-        stringResource(R.string.manga_reader_side_padding),
-        settings.sidePaddingPercent,
-        0..45
-    ) {
-        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SIDE_PADDING, it))
+    if (isWebtoon) {
+        SettingSlider(
+            stringResource(R.string.manga_reader_side_padding),
+            settings.sidePaddingPercent,
+            0..45
+        ) {
+            onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SIDE_PADDING, it))
+        }
+    } else {
+        SettingDropdown(
+            stringResource(R.string.manga_reader_scale_type),
+            settings.pageScaleType,
+            arrayOf(
+                R.string.manga_reader_scale_fit_screen,
+                R.string.manga_reader_scale_stretch,
+                R.string.manga_reader_scale_fit_width,
+                R.string.manga_reader_scale_fit_height,
+                R.string.manga_reader_scale_original,
+                R.string.manga_reader_scale_smart,
+            ),
+            MangaReaderSettingKey.PAGE_SCALE_TYPE,
+            onIntent,
+        )
+        if (isHorizontalPaged) {
+            SettingDropdown(
+                stringResource(R.string.manga_reader_zoom_start),
+                settings.zoomStartPosition,
+                arrayOf(
+                    R.string.manga_reader_position_automatic,
+                    R.string.manga_reader_position_left,
+                    R.string.manga_reader_position_right,
+                    R.string.manga_reader_position_center,
+                ),
+                MangaReaderSettingKey.ZOOM_START_POSITION,
+                onIntent,
+            )
+        }
+        SettingDropdown(
+            stringResource(R.string.manga_reader_wide_page),
+            settings.widePageMode,
+            arrayOf(
+                R.string.manga_reader_wide_normal,
+                R.string.manga_reader_wide_fit_width,
+                R.string.manga_reader_wide_rotate,
+            ),
+            MangaReaderSettingKey.WIDE_PAGE_MODE,
+            onIntent,
+        )
+        if (isHorizontalPaged) {
+            SettingDropdown(
+                stringResource(R.string.manga_reader_double_page),
+                settings.doublePageMode,
+                arrayOf(
+                    R.string.manga_reader_mode_off,
+                    R.string.manga_reader_mode_landscape,
+                    R.string.manga_reader_mode_always,
+                ),
+                MangaReaderSettingKey.DOUBLE_PAGE_MODE,
+                onIntent,
+            )
+        }
     }
     SettingSlider(
         stringResource(R.string.manga_reader_preload_pages),
@@ -402,6 +550,81 @@ private fun FooterSettingsContent(
     )
 }
 
+@Composable
+private fun MenuSettingsContent(
+    settings: MangaReaderSettings,
+    onPickSeedColor: () -> Unit,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    SettingDropdown(
+        label = stringResource(R.string.read_menu_color_source),
+        value = settings.menuColorSource,
+        entryLabels = arrayOf(
+            R.string.manga_reader_menu_color_background,
+            R.string.manga_reader_menu_color_page,
+            R.string.manga_reader_menu_color_system,
+            R.string.manga_reader_menu_color_custom,
+        ),
+        key = MangaReaderSettingKey.MENU_COLOR_SOURCE,
+        onIntent = onIntent,
+    )
+    if (settings.menuColorSource == 3) {
+        TinyColorSettingItem(
+            title = stringResource(R.string.seed_color),
+            colorValue = settings.menuSeedColor.toArgb(),
+            onClick = onPickSeedColor,
+        )
+    }
+    val paletteEntries = stringArrayResource(R.array.paletteStyle)
+    val paletteValues = stringArrayResource(R.array.paletteStyle_value)
+    TinyDropdownSettingItem(
+        title = stringResource(R.string.palette_style),
+        selectedValue = settings.menuPaletteStyle,
+        displayEntries = paletteEntries,
+        entryValues = paletteValues,
+        onValueChange = { onIntent(MangaReaderIntent.UpdateMenuPaletteStyle(it)) },
+    )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.manga_reader_menu_top_bar_liquid_glass),
+        description = stringResource(R.string.manga_reader_menu_top_bar_liquid_glass_summary),
+        checked = settings.menuTopBarLiquidGlass,
+        onCheckedChange = {
+            updateBoolean(onIntent, MangaReaderSettingKey.MENU_TOP_BAR_LIQUID_GLASS, it)
+        },
+    )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.manga_reader_menu_bottom_bar_liquid_glass),
+        description = stringResource(R.string.manga_reader_menu_bottom_bar_liquid_glass_summary),
+        checked = settings.menuBottomBarLiquidGlass,
+        onCheckedChange = {
+            updateBoolean(onIntent, MangaReaderSettingKey.MENU_BOTTOM_BAR_LIQUID_GLASS, it)
+        },
+    )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.manga_reader_menu_bottom_bar_floating),
+        checked = settings.menuBottomBarFloating,
+        onCheckedChange = {
+            updateBoolean(onIntent, MangaReaderSettingKey.MENU_BOTTOM_BAR_FLOATING, it)
+        },
+    )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.manga_reader_menu_bottom_bar_blur),
+        description = stringResource(R.string.manga_reader_menu_bottom_bar_blur_summary),
+        checked = settings.menuBottomBarBlur,
+        onCheckedChange = {
+            updateBoolean(onIntent, MangaReaderSettingKey.MENU_BOTTOM_BAR_BLUR, it)
+        },
+    )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.manga_reader_menu_top_bar_compact),
+        description = stringResource(R.string.manga_reader_menu_top_bar_compact_summary),
+        checked = settings.menuTopBarCompact,
+        onCheckedChange = {
+            updateBoolean(onIntent, MangaReaderSettingKey.MENU_TOP_BAR_COMPACT, it)
+        },
+    )
+}
+
 @Composable
 private fun FilterSettingsContent(
     settings: MangaReaderSettings,
@@ -409,6 +632,10 @@ private fun FilterSettingsContent(
     onPickFilter: () -> Unit,
     onIntent: (MangaReaderIntent) -> Unit,
 ) {
+    SettingSwitch(
+        stringResource(R.string.manga_reader_auto_background),
+        settings.autoBackground,
+    ) { updateBoolean(onIntent, MangaReaderSettingKey.AUTO_BACKGROUND, it) }
     SettingSwitch(
         stringResource(R.string.manga_reader_grayscale),
         settings.enableGray
@@ -427,11 +654,13 @@ private fun FilterSettingsContent(
     ) {
         onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.EINK_THRESHOLD, it))
     }
-    TinyColorSettingItem(
-        title = stringResource(R.string.background_color),
-        colorValue = settings.backgroundColor.toArgb(),
-        onClick = onPickBackground,
-    )
+    if (!settings.autoBackground) {
+        TinyColorSettingItem(
+            title = stringResource(R.string.background_color),
+            colorValue = settings.backgroundColor.toArgb(),
+            onClick = onPickBackground,
+        )
+    }
     TinyColorSettingItem(
         title = stringResource(R.string.manga_reader_display_filter),
         colorValue = Color(
@@ -484,10 +713,12 @@ private fun ClickActionsSettingsContent(
     onIntent: (MangaReaderIntent) -> Unit,
 ) {
     val labels = mapOf(
-        -1 to stringResource(R.string.previous_chapter),
+        -1 to stringResource(R.string.non_action),
         0 to stringResource(R.string.manga_reader_menu),
         1 to stringResource(R.string.manga_reader_next_page),
         2 to stringResource(R.string.manga_reader_previous_page),
+        3 to stringResource(R.string.next_chapter),
+        4 to stringResource(R.string.previous_chapter),
     )
     repeat(3) { row ->
         Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
@@ -521,6 +752,23 @@ private fun updateInt(
     value: Int,
 ) = onIntent(MangaReaderIntent.UpdateSetting(key, value))
 
+@Composable
+private fun SettingDropdown(
+    label: String,
+    value: Int,
+    entryLabels: Array<Int>,
+    key: MangaReaderSettingKey,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    TinyDropdownSettingItem(
+        title = label,
+        selectedValue = value.toString(),
+        displayEntries = entryLabels.map { stringResource(it) }.toTypedArray(),
+        entryValues = entryLabels.indices.map(Int::toString).toTypedArray(),
+        onValueChange = { updateInt(onIntent, key, it.toInt()) },
+    )
+}
+
 @Composable
 private fun SettingSwitch(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
     TinySwitchSettingItem(

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

@@ -76,6 +76,7 @@ class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
                 show = true,
                 bookUrl = state.bookUrl,
                 initialTab = ReaderBookSheetTab.Toc,
+                currentChapterIndex = state.pendingChapterIndex ?: state.chapterIndex,
                 onDismissRequest = { readerViewModel.onIntent(MangaReaderIntent.DismissSheet) },
                 onChapterClick = { chapterIndex, pageIndex ->
                     readerViewModel.onIntent(MangaReaderIntent.DismissSheet)

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

@@ -0,0 +1,29 @@
+package io.legado.app.ui.book.manga.config
+
+object MangaPageScaleType {
+    const val FIT_SCREEN = 0
+    const val STRETCH = 1
+    const val FIT_WIDTH = 2
+    const val FIT_HEIGHT = 3
+    const val ORIGINAL = 4
+    const val SMART_FIT = 5
+}
+
+object MangaZoomStartPosition {
+    const val AUTOMATIC = 0
+    const val LEFT = 1
+    const val RIGHT = 2
+    const val CENTER = 3
+}
+
+object MangaWidePageMode {
+    const val NORMAL = 0
+    const val FIT_WIDTH = 1
+    const val ROTATE_TO_FIT = 2
+}
+
+object MangaDoublePageMode {
+    const val OFF = 0
+    const val LANDSCAPE = 1
+    const val ALWAYS = 2
+}

+ 52 - 3
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt

@@ -54,6 +54,7 @@ import androidx.compose.material.icons.outlined.DownloadForOffline
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -129,6 +130,7 @@ fun ReaderBookSheetRoute(
     initialTab: ReaderBookSheetTab,
     onDismissRequest: () -> Unit,
     onChapterClick: (chapterIndex: Int, chapterPos: Int) -> Unit,
+    currentChapterIndex: Int? = null,
     onOpenFullBookInfo: () -> Unit,
     /** 书签页跳转:携带完整书签供跳转前校验。 */
     onBookmarkNavigate: (Bookmark) -> Unit = { _ -> },
@@ -194,6 +196,7 @@ fun ReaderBookSheetRoute(
         },
         onIntent = viewModel::onIntent,
         onChapterClick = { index -> onChapterClick(index, 0) },
+        currentChapterIndex = currentChapterIndex,
         onBookmarkNavigate = onBookmarkNavigate,
         onMarkingNavigate = onMarkingNavigate,
         onMarkingEdit = onMarkingEdit,
@@ -243,6 +246,7 @@ private fun ReaderBookSheet(
     onDismissRequest: () -> Unit,
     onIntent: (TocIntent) -> Unit,
     onChapterClick: (Int) -> Unit,
+    currentChapterIndex: Int?,
     onBookmarkNavigate: (Bookmark) -> Unit,
     onMarkingNavigate: (TocMarkingItemUi) -> Unit,
     onMarkingEdit: (String) -> Unit,
@@ -334,6 +338,7 @@ private fun ReaderBookSheet(
                         state = state,
                         onIntent = onIntent,
                         onChapterClick = onChapterClick,
+                        currentChapterIndex = currentChapterIndex,
                         onEditLocalTocRule = onEditLocalTocRule,
                     )
 
@@ -475,6 +480,38 @@ internal fun ReaderBookHeader(
     }
 }
 
+@Stable
+data class ReaderBookHeaderState(
+    val bookUrl: String,
+    val sourceUrl: String,
+    val sourceName: String,
+    val title: String,
+    val author: String,
+    val coverUrl: String? = null,
+    val customCoverUrl: String? = null,
+    val chapterTitle: String,
+    val chapterIndex: Int,
+    val chapterCount: Int,
+)
+
+@Composable
+internal fun ReaderBookHeader(state: ReaderBookHeaderState) {
+    ReaderBookHeader(
+        book = Book(
+            bookUrl = state.bookUrl,
+            origin = state.sourceUrl,
+            originName = state.sourceName,
+            name = state.title,
+            author = state.author,
+            coverUrl = state.coverUrl,
+            customCoverUrl = state.customCoverUrl,
+            durChapterTitle = state.chapterTitle,
+            durChapterIndex = state.chapterIndex,
+            totalChapterNum = state.chapterCount,
+        ),
+    )
+}
+
 @Composable
 private fun ReaderBookSourceDropdown(
     expanded: Boolean,
@@ -668,6 +705,7 @@ private fun ReaderBookTocPage(
     state: TocUiState,
     onIntent: (TocIntent) -> Unit,
     onChapterClick: (Int) -> Unit,
+    currentChapterIndex: Int?,
     onEditLocalTocRule: (String?) -> Unit,
 ) {
     val action = state.action
@@ -714,8 +752,15 @@ private fun ReaderBookTocPage(
     LaunchedEffect(selected) {
         fabExpanded = false
     }
-    LaunchedEffect(state.book?.totalChapterNum, state.isReverse, action.items.isNotEmpty()) {
-        val currentIndex = action.items.indexOfFirst { it.isDur }
+    LaunchedEffect(
+        state.book?.totalChapterNum,
+        state.isReverse,
+        action.items.isNotEmpty(),
+        currentChapterIndex,
+    ) {
+        val currentIndex = action.items.indexOfFirst {
+            it.id == (currentChapterIndex ?: state.book?.durChapterIndex)
+        }
         if (currentIndex >= 0) listState.scrollToItem(currentIndex)
     }
 
@@ -771,6 +816,7 @@ private fun ReaderBookTocPage(
                 listState = listState,
                 onIntent = onIntent,
                 onChapterClick = onChapterClick,
+                currentChapterIndex = currentChapterIndex,
             )
             AppFloatingActionButtonMenu(
                 expanded = fabExpanded,
@@ -793,6 +839,7 @@ private fun ReaderSheetChapterList(
     listState: androidx.compose.foundation.lazy.LazyListState,
     onIntent: (TocIntent) -> Unit,
     onChapterClick: (Int) -> Unit,
+    currentChapterIndex: Int?,
 ) {
     FastScrollLazyColumn(
         state = listState,
@@ -814,7 +861,9 @@ private fun ReaderSheetChapterList(
                 )
             } else {
                 ReaderSheetChapterItem(
-                    item = item,
+                    item = item.copy(
+                        isDur = item.id == (currentChapterIndex ?: state.book?.durChapterIndex),
+                    ),
                     showWordCount = state.action.showWordCount,
                     selectionMode = state.action.selectedIds.isNotEmpty(),
                     onClick = {

+ 2 - 67
app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderMoreActionsSheet.kt

@@ -66,6 +66,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.reader.ReaderMenuActionSquare
 import io.legado.app.ui.widget.components.text.AppText
 
 private data class MoreActionSpec(
@@ -199,7 +200,7 @@ private fun ActionSquareHost(
     var expanded by remember(action.id) { mutableStateOf(false) }
     val hasMore = action.id == "change_source" || action.id == "refresh"
     Box(modifier = modifier) {
-        SquareAction(
+        ReaderMenuActionSquare(
             icon = action.icon,
             text = action.label,
             selected = action.selected,
@@ -259,72 +260,6 @@ private fun ActionSquareHost(
     }
 }
 
-@Composable
-private fun SquareAction(
-    icon: ImageVector,
-    text: String,
-    selected: Boolean,
-    hasMore: Boolean,
-    onClick: () -> Unit,
-    onMoreClick: () -> Unit,
-) {
-    Column(
-        modifier = Modifier
-            .fillMaxWidth()
-            .aspectRatio(1f)
-            .clip(RoundedCornerShape(16.dp))
-            .background(
-                if (selected) LegadoTheme.colorScheme.secondaryContainer
-                else LegadoTheme.colorScheme.surfaceContainerLow
-            ),
-    ) {
-        Column(
-            modifier = Modifier
-                .fillMaxWidth()
-                .weight(1f)
-                .clickable(onClick = onClick),
-            horizontalAlignment = Alignment.CenterHorizontally,
-            verticalArrangement = Arrangement.Center,
-        ) {
-            AppIcon(
-                icon,
-                contentDescription = null,
-                modifier = Modifier.size(20.dp)
-            )
-
-            val space = if (hasMore) 6.dp else 8.dp
-            Spacer(
-                modifier = Modifier.height(space)
-            )
-
-            AppText(
-                text = text,
-                style = LegadoTheme.typography.labelSmall,
-                textAlign = TextAlign.Center,
-                maxLines = 1,
-                overflow = TextOverflow.Ellipsis,
-                modifier = Modifier.padding(horizontal = 4.dp),
-            )
-        }
-        if (hasMore) {
-            HorizontalDivider(color = LegadoTheme.colorScheme.surfaceContainer)
-            Box(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .height(16.dp)
-                    .clickable(onClick = onMoreClick),
-                contentAlignment = Alignment.Center,
-            ) {
-                Icon(
-                    imageVector = Icons.Default.ExpandMore,
-                    contentDescription = stringResource(R.string.more_actions),
-                    modifier = Modifier.size(12.dp),
-                )
-            }
-        }
-    }
-}
-
 @Composable
 private fun MoreActionsConfigContent(
     items: List<ReadBookButtonConfigItem>,

+ 3 - 1
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -17,7 +17,9 @@ object MainNavigator {
 
     var backNavigationInProgress = false
         private set
-    private val navigationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+    private val navigationScope by lazy(LazyThreadSafetyMode.NONE) {
+        CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+    }
     private var backNavigationResetJob: Job? = null
 
     fun navigateToRoute(backStack: MutableList<NavKey>, route: NavKey) {

+ 1 - 0
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumTonalButton.kt

@@ -24,6 +24,7 @@ fun MediumTonalButton(
         enabled = enabled,
         selected = selected,
         onLongClick = onLongClick,
+        enforceMinimumInteractiveSize = false,
         size = if (text == null) MediumSeriesIconButtonSize else null,
         style = SeriesIconButtonStyle.Tonal
     ) { contentColor ->

+ 14 - 7
app/src/main/java/io/legado/app/ui/widget/components/pager/PagerHeight.kt

@@ -1,7 +1,9 @@
 package io.legado.app.ui.widget.components.pager
 
 import androidx.compose.animation.core.FiniteAnimationSpec
+import androidx.compose.animation.core.Spring
 import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
 import androidx.compose.foundation.pager.PagerState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.State
@@ -63,15 +65,20 @@ fun rememberPagerAnimatedHeight(
             }
         }
     }
-    if (heightAnimationSpec == null) {
+    if (targetHeight.value == Dp.Unspecified) {
         return targetHeight
     }
-    return if (targetHeight.value == Dp.Unspecified) {
-        targetHeight
-    } else {
-        animateDpAsState(
-            targetValue = targetHeight.value,
-            animationSpec = heightAnimationSpec,
+    // 快速滑过不同高度页面(如 高-低-高)时线性插值目标会剧烈跳动;
+    // 默认用弹簧让高度平滑跟随、阻尼振荡,传入 heightAnimationSpec 可覆盖。
+    val smoothSpec = remember(heightAnimationSpec) {
+        heightAnimationSpec ?: spring(
+            dampingRatio = Spring.DampingRatioNoBouncy,
+            stiffness = Spring.StiffnessMedium,
         )
     }
+    return animateDpAsState(
+        targetValue = targetHeight.value,
+        animationSpec = smoothSpec,
+        label = "PagerAnimatedHeight",
+    )
 }

+ 86 - 0
app/src/main/java/io/legado/app/ui/widget/components/reader/ReaderMenuActionSquare.kt

@@ -0,0 +1,86 @@
+package io.legado.app.ui.widget.components.reader
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.text.AppText
+
+@Composable
+fun ReaderMenuActionSquare(
+    icon: ImageVector,
+    text: String,
+    modifier: Modifier = Modifier,
+    selected: Boolean = false,
+    hasMore: Boolean = false,
+    onClick: () -> Unit,
+    onMoreClick: (() -> Unit)? = null,
+) {
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .aspectRatio(1f)
+            .clip(RoundedCornerShape(16.dp))
+            .background(
+                if (selected) LegadoTheme.colorScheme.secondaryContainer
+                else LegadoTheme.colorScheme.surfaceContainerLow
+            ),
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .weight(1f)
+                .clickable(onClick = onClick),
+            horizontalAlignment = Alignment.CenterHorizontally,
+            verticalArrangement = Arrangement.Center,
+        ) {
+            AppIcon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
+            Spacer(Modifier.height(if (hasMore) 6.dp else 8.dp))
+            AppText(
+                text = text,
+                style = LegadoTheme.typography.labelSmall,
+                textAlign = TextAlign.Center,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                modifier = Modifier.padding(horizontal = 4.dp),
+            )
+        }
+        if (hasMore && onMoreClick != null) {
+            HorizontalDivider(color = LegadoTheme.colorScheme.surfaceContainer)
+            Box(
+                modifier = Modifier.fillMaxWidth().height(16.dp).clickable(onClick = onMoreClick),
+                contentAlignment = Alignment.Center,
+            ) {
+                Icon(
+                    imageVector = Icons.Default.ExpandMore,
+                    contentDescription = stringResource(R.string.more_actions),
+                    modifier = Modifier.size(12.dp),
+                )
+            }
+        }
+    }
+}

+ 0 - 73
app/src/main/java/io/legado/app/ui/widget/components/reader/ReaderMenuPrimitives.kt

@@ -6,31 +6,21 @@ import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.clickable
-import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.BoxScope
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
 import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppSlider
@@ -115,69 +105,6 @@ data class ReaderMenuAction(
     val onClick: () -> Unit,
 )
 
-@Composable
-fun ReaderMenuToolRow(
-    actions: List<ReaderMenuAction>,
-    modifier: Modifier = Modifier,
-    columns: Int = 5,
-) {
-    Row(
-        modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp),
-        horizontalArrangement = Arrangement.SpaceBetween,
-    ) {
-        actions.take(columns).forEach { action ->
-            ReaderMenuIconButton(
-                icon = action.icon,
-                description = action.description,
-                onClick = action.onClick,
-                onLongClick = action.onLongClick,
-            )
-        }
-        repeat((columns - actions.size).coerceAtLeast(0)) {
-            Spacer(Modifier.size(48.dp))
-        }
-    }
-}
-
-@Composable
-fun ReaderMenuIconButton(
-    icon: ImageVector,
-    description: String,
-    modifier: Modifier = Modifier,
-    onLongClick: (() -> Unit)? = null,
-    onClick: () -> Unit,
-) {
-    if (onLongClick == null) {
-        IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
-            Icon(
-                imageVector = icon,
-                contentDescription = description,
-                tint = LegadoTheme.colorScheme.onSurface,
-                modifier = Modifier.size(22.dp),
-            )
-        }
-    } else {
-        Box(
-            modifier = modifier
-                .size(48.dp)
-                .clip(CircleShape)
-                .combinedClickable(
-                    onClick = onClick,
-                    onLongClick = onLongClick,
-                    role = Role.Button,
-                ),
-            contentAlignment = Alignment.Center,
-        ) {
-            Icon(
-                imageVector = icon,
-                contentDescription = description,
-                tint = LegadoTheme.colorScheme.onSurface,
-                modifier = Modifier.size(22.dp),
-            )
-        }
-    }
-}
-
 @Composable
 fun ReaderMenuSlider(
     value: Float,

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

@@ -3053,6 +3053,20 @@
     <string name="ai_auto_group_error_unexpected">AI 自动分组失败。</string>
     <string name="ai_auto_group_result">已创建 %1$d 个分组,复用 %2$d 个分组,更新 %3$d 本书,跳过 %4$d 本书。</string>
     <string name="manga_reader_auto_read">自动阅读</string>
+    <string name="manga_reader_menu_layout">菜单布局</string>
+    <string name="manga_reader_menu_top_bar_liquid_glass">顶栏液态玻璃</string>
+    <string name="manga_reader_menu_bottom_bar_liquid_glass">底栏液态玻璃</string>
+    <string name="manga_reader_menu_top_bar_liquid_glass_summary">顶栏使用液态玻璃效果,需要 Android 13 及以上</string>
+    <string name="manga_reader_menu_bottom_bar_liquid_glass_summary">底栏使用液态玻璃效果,需要 Android 13 及以上</string>
+    <string name="manga_reader_menu_bottom_bar_floating">底栏悬浮菜单</string>
+    <string name="manga_reader_menu_bottom_bar_blur">底栏模糊</string>
+    <string name="manga_reader_menu_bottom_bar_blur_summary">底栏背景使用毛玻璃模糊效果</string>
+    <string name="manga_reader_menu_top_bar_compact">精简顶栏按钮</string>
+    <string name="manga_reader_menu_top_bar_compact_summary">顶栏只保留返回和更多按钮</string>
+    <string name="manga_reader_menu_color_background">跟随阅读背景颜色</string>
+    <string name="manga_reader_menu_color_page">跟随当前漫画页颜色</string>
+    <string name="manga_reader_menu_color_system">跟随系统颜色</string>
+    <string name="manga_reader_menu_color_custom">自定义种子色</string>
     <string name="manga_reader_display_filter">显示与滤镜</string>
     <string name="manga_reader_click_areas">九宫格点击区域</string>
     <string name="manga_reader_source_actions">书源操作</string>
@@ -3099,6 +3113,17 @@
     <string name="manga_reader_next_page">下一页</string>
     <string name="manga_reader_previous_page">上一页</string>
     <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_no_previous_chapter">没有上一章</string>
+    <string name="manga_reader_no_next_chapter">没有下一章</string>
+    <string name="manga_reader_adjacent_failed">%1$s\n点击重试</string>
+    <string name="manga_reader_transition_previous">上一章</string>
+    <string name="manga_reader_transition_current">当前章节</string>
+    <string name="manga_reader_transition_finished">已读完</string>
+    <string name="manga_reader_transition_next">下一章</string>
+    <string name="manga_reader_transition_chapter_number">第 %1$d 章</string>
+    <string name="manga_reader_unknown_error">章节加载失败</string>
     <string name="manga_reader_buy_chapter">购买当前章节</string>
     <string name="manga_reader_page_description">%1$s,第 %2$d 页</string>
     <string name="manga_reader_page_label">页码</string>
@@ -3119,4 +3144,25 @@
     <string name="manga_reader_details_error">详情页出错:%1$s</string>
     <string name="manga_reader_no_source">没有合适书源</string>
     <string name="manga_reader_auto_source_failed">自动换源失败</string>
+    <string name="manga_reader_auto_background">自动页面背景色</string>
+    <string name="manga_reader_scale_type">页面缩放模式</string>
+    <string name="manga_reader_scale_fit_screen">适应屏幕</string>
+    <string name="manga_reader_scale_stretch">拉伸</string>
+    <string name="manga_reader_scale_fit_width">适应宽度</string>
+    <string name="manga_reader_scale_fit_height">适应高度</string>
+    <string name="manga_reader_scale_original">原始尺寸</string>
+    <string name="manga_reader_scale_smart">智能适应</string>
+    <string name="manga_reader_zoom_start">缩放起始位置</string>
+    <string name="manga_reader_position_automatic">自动</string>
+    <string name="manga_reader_position_left">左侧</string>
+    <string name="manga_reader_position_right">右侧</string>
+    <string name="manga_reader_position_center">居中</string>
+    <string name="manga_reader_wide_page">宽页处理</string>
+    <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_double_page">双页模式</string>
+    <string name="manga_reader_mode_off">关闭</string>
+    <string name="manga_reader_mode_landscape">仅横屏</string>
+    <string name="manga_reader_mode_always">始终</string>
 </resources>

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

@@ -3058,6 +3058,20 @@
     <string name="ai_auto_group_error_unexpected">AI auto-grouping failed.</string>
     <string name="ai_auto_group_result">Created %1$d groups, reused %2$d groups, updated %3$d books, and skipped %4$d books.</string>
     <string name="manga_reader_auto_read">Auto read</string>
+    <string name="manga_reader_menu_layout">Menu layout</string>
+    <string name="manga_reader_menu_top_bar_liquid_glass">Liquid glass top bar</string>
+    <string name="manga_reader_menu_bottom_bar_liquid_glass">Liquid glass bottom bar</string>
+    <string name="manga_reader_menu_top_bar_liquid_glass_summary">Apply liquid glass to the top bar. Requires Android 13+</string>
+    <string name="manga_reader_menu_bottom_bar_liquid_glass_summary">Apply liquid glass to the bottom bar. Requires Android 13+</string>
+    <string name="manga_reader_menu_bottom_bar_floating">Floating bottom menu</string>
+    <string name="manga_reader_menu_bottom_bar_blur">Blur bottom bar</string>
+    <string name="manga_reader_menu_bottom_bar_blur_summary">Apply frosted blur to the bottom bar background</string>
+    <string name="manga_reader_menu_top_bar_compact">Compact top bar buttons</string>
+    <string name="manga_reader_menu_top_bar_compact_summary">Show only back and more buttons in the top bar</string>
+    <string name="manga_reader_menu_color_background">Follow reader background</string>
+    <string name="manga_reader_menu_color_page">Follow current manga page</string>
+    <string name="manga_reader_menu_color_system">Follow system colors</string>
+    <string name="manga_reader_menu_color_custom">Custom seed color</string>
     <string name="manga_reader_display_filter">Display and filters</string>
     <string name="manga_reader_click_areas">Nine-grid tap areas</string>
     <string name="manga_reader_source_actions">Source actions</string>
@@ -3104,6 +3118,17 @@
     <string name="manga_reader_next_page">Next page</string>
     <string name="manga_reader_previous_page">Previous page</string>
     <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_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>
+    <string name="manga_reader_transition_previous">Previous chapter</string>
+    <string name="manga_reader_transition_current">Current chapter</string>
+    <string name="manga_reader_transition_finished">Finished</string>
+    <string name="manga_reader_transition_next">Next chapter</string>
+    <string name="manga_reader_transition_chapter_number">Chapter %1$d</string>
+    <string name="manga_reader_unknown_error">Unable to load chapter</string>
     <string name="manga_reader_buy_chapter">Purchase current chapter</string>
     <string name="manga_reader_page_description">%1$s, page %2$d</string>
     <string name="manga_reader_page_label">Page</string>
@@ -3124,4 +3149,25 @@
     <string name="manga_reader_details_error">Details page error: %1$s</string>
     <string name="manga_reader_no_source">No suitable source</string>
     <string name="manga_reader_auto_source_failed">Automatic source change failed</string>
+    <string name="manga_reader_auto_background">Automatic page background</string>
+    <string name="manga_reader_scale_type">Page scale type</string>
+    <string name="manga_reader_scale_fit_screen">Fit screen</string>
+    <string name="manga_reader_scale_stretch">Stretch</string>
+    <string name="manga_reader_scale_fit_width">Fit width</string>
+    <string name="manga_reader_scale_fit_height">Fit height</string>
+    <string name="manga_reader_scale_original">Original size</string>
+    <string name="manga_reader_scale_smart">Smart fit</string>
+    <string name="manga_reader_zoom_start">Zoom start position</string>
+    <string name="manga_reader_position_automatic">Automatic</string>
+    <string name="manga_reader_position_left">Left</string>
+    <string name="manga_reader_position_right">Right</string>
+    <string name="manga_reader_position_center">Center</string>
+    <string name="manga_reader_wide_page">Wide page handling</string>
+    <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_double_page">Dual page mode</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>
 </resources>

+ 22 - 2
app/src/test/java/io/legado/app/data/repository/MangaSettingsMappingTest.kt

@@ -22,14 +22,14 @@ import kotlin.concurrent.thread
 class MangaSettingsMappingTest {
 
     @Test
-    fun `漫画设置 28 键写映射逐字段对应`() {
+    fun `漫画设置 38 键写映射逐字段对应`() {
         mangaMappingSamples().forEach { settings ->
             assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
     }
 
     @Test
-    fun `漫画设置 28 键读映射逐字段对应`() {
+    fun `漫画设置 38 键读映射逐字段对应`() {
         mangaMappingSamples().forEach { expected ->
             assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toMangaSettings())
         }
@@ -179,6 +179,11 @@ private fun mangaMappingSamples(): List<MangaSettings> {
         autoPageSpeed = 33,
         footerConfig = "manga-footer",
         background = 0xFF123456.toInt(),
+        autoBackground = true,
+        pageScaleType = 5,
+        zoomStartPosition = 3,
+        widePageMode = 2,
+        doublePageMode = 2,
         colorFilter = "manga-filter",
         eInkThreshold = 44,
         webtoonSidePaddingDp = 55,
@@ -205,6 +210,11 @@ private fun mangaMappingSamples(): List<MangaSettings> {
         base.copy(enableGray = true),
         base.copy(volumeKeyPage = true),
         base.copy(reverseVolumeKeyPage = true),
+        base.copy(menuTopBarLiquidGlass = true),
+        base.copy(menuBottomBarLiquidGlass = true),
+        base.copy(menuBottomBarFloating = false),
+        base.copy(menuBottomBarBlur = true),
+        base.copy(menuTopBarCompact = true),
     )
 }
 
@@ -220,6 +230,11 @@ private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.disableClickScroll to disableClickScroll,
     PreferKey.mangaLongClick to longClick,
     PreferKey.mangaBackground to background,
+    PreferKey.mangaAutoBackground to autoBackground,
+    PreferKey.mangaPageScaleType to pageScaleType,
+    PreferKey.mangaZoomStartPosition to zoomStartPosition,
+    PreferKey.mangaWidePageMode to widePageMode,
+    PreferKey.mangaDoublePageMode to doublePageMode,
     PreferKey.mangaColorFilter to colorFilter,
     PreferKey.hideMangaTitle to hideTitle,
     PreferKey.enableMangaEInk to enableEInk,
@@ -228,6 +243,11 @@ private fun MangaSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.webtoonSidePaddingDp to webtoonSidePaddingDp,
     PreferKey.mangaVolumeKeyPage to volumeKeyPage,
     PreferKey.reverseVolumeKeyPage to reverseVolumeKeyPage,
+    PreferKey.mangaMenuTopBarLiquidGlass to menuTopBarLiquidGlass,
+    PreferKey.mangaMenuBottomBarLiquidGlass to menuBottomBarLiquidGlass,
+    PreferKey.mangaMenuBottomBarFloating to menuBottomBarFloating,
+    PreferKey.mangaMenuBottomBarBlur to menuBottomBarBlur,
+    PreferKey.mangaMenuTopBarCompact to menuTopBarCompact,
     PreferKey.mangaClickActionTL to clickActionTL,
     PreferKey.mangaClickActionTC to clickActionTC,
     PreferKey.mangaClickActionTR to clickActionTR,

+ 273 - 0
app/src/test/java/io/legado/app/data/repository/manga/DefaultMangaReaderSessionTest.kt

@@ -0,0 +1,273 @@
+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.MangaChapterContent
+import io.legado.app.domain.model.manga.MangaChapterState
+import io.legado.app.domain.model.manga.MangaPageContent
+import io.legado.app.domain.model.manga.MangaProgressState
+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.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
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DefaultMangaReaderSessionTest {
+
+    @Test
+    fun `opening a new book cancels and rejects old chapter results`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        session.execute(MangaSessionCommand.Open("book-b", true, false))
+        advanceUntilIdle()
+
+        gateway.complete("book-a", 0)
+        advanceUntilIdle()
+        assertEquals("book-b", session.state.value.book?.bookUrl)
+        assertTrue(session.state.value.currentChapter is MangaChapterState.Loading)
+
+        gateway.complete("book-b", 0)
+        advanceUntilIdle()
+        val chapter = session.state.value.currentChapter as MangaChapterState.Ready
+        assertEquals("book-b/0", chapter.chapter.pages.single().imageUrl)
+        session.close()
+    }
+
+    @Test
+    fun `failed chapter can be retried without a leaked loading marker`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        gateway.fail("book-a", 0)
+        advanceUntilIdle()
+        assertTrue(session.state.value.currentChapter is MangaChapterState.Failed)
+
+        session.execute(MangaSessionCommand.RetryChapter(0))
+        advanceUntilIdle()
+        assertTrue(session.state.value.currentChapter is MangaChapterState.Loading)
+        gateway.complete("book-a", 0)
+        advanceUntilIdle()
+        assertTrue(session.state.value.currentChapter is MangaChapterState.Ready)
+        session.close()
+    }
+
+    @Test
+    fun `closing session persists progress and ends reading lifecycle`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        session.execute(MangaSessionCommand.Resume)
+        session.execute(MangaSessionCommand.VisiblePageChanged(0, 7))
+        advanceUntilIdle()
+        session.close()
+        advanceUntilIdle()
+
+        assertEquals(Triple("book-a", 0, 7), gateway.persisted.single())
+        assertEquals(listOf("book-a"), gateway.paused)
+    }
+
+    @Test
+    fun `prefetch is bounded to the active session window`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.PrefetchCountChanged(2))
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+
+        assertEquals(listOf("book-a" to 2, "book-a" to 3), gateway.prefetched)
+        session.close()
+    }
+
+    @Test
+    fun `book presentation changes survive later chapter state updates`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        gateway.presentation.value = MangaBookPresentation(scrollMode = 2, sidePaddingDp = null)
+        gateway.complete("book-a", 0)
+        advanceUntilIdle()
+
+        assertEquals(2, session.state.value.book?.scrollMode)
+        session.close()
+    }
+
+    @Test
+    fun `promoting to a visible next chapter keeps current chapter data without reload`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        gateway.complete("book-a", 0)
+        advanceUntilIdle()
+        session.execute(MangaSessionCommand.VisiblePageChanged(0, 0))
+        advanceUntilIdle()
+        gateway.complete("book-a", 1)
+        advanceUntilIdle()
+        assertEquals(0, session.state.value.chapterIndex)
+        assertTrue(session.state.value.currentChapter is MangaChapterState.Ready)
+        assertTrue(session.state.value.nextChapter is MangaChapterState.Ready)
+
+        session.execute(MangaSessionCommand.PromoteVisibleChapter(1, 0))
+        advanceUntilIdle()
+
+        val state = session.state.value
+        assertEquals(1, state.chapterIndex)
+        assertEquals(0, state.pageIndex)
+        // 已加载的章节原位保留,不清空重载
+        assertEquals(0, (state.previousChapter as MangaChapterState.Ready).chapter.chapterIndex)
+        assertEquals(1, (state.currentChapter as MangaChapterState.Ready).chapter.chapterIndex)
+        // 只加载新的远端章节(chapter 2)
+        assertEquals("book-a" to 2, gateway.loadedRequests.lastOrNull())
+        session.close()
+    }
+
+    @Test
+    fun `promoting to a visible previous chapter keeps current chapter data without reload`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        session.execute(MangaSessionCommand.OpenChapter(2, 0))
+        advanceUntilIdle()
+        gateway.complete("book-a", 2)
+        advanceUntilIdle()
+        session.execute(MangaSessionCommand.VisiblePageChanged(2, 0))
+        advanceUntilIdle()
+        gateway.complete("book-a", 1)
+        advanceUntilIdle()
+        assertEquals(2, session.state.value.chapterIndex)
+        assertTrue(session.state.value.previousChapter is MangaChapterState.Ready)
+
+        session.execute(MangaSessionCommand.PromoteVisibleChapter(1, 0))
+        advanceUntilIdle()
+
+        val state = session.state.value
+        assertEquals(1, state.chapterIndex)
+        assertEquals(0, state.pageIndex)
+        assertEquals(1, (state.currentChapter as MangaChapterState.Ready).chapter.chapterIndex)
+        assertEquals(2, (state.nextChapter as MangaChapterState.Ready).chapter.chapterIndex)
+        assertEquals("book-a" to 0, gateway.loadedRequests.lastOrNull())
+        session.close()
+    }
+
+    @Test
+    fun `promoting outside the chapter range is ignored`() = runTest {
+        val gateway = FakeGateway()
+        val dispatcher = StandardTestDispatcher(testScheduler)
+        val session = DefaultMangaReaderSession(gateway, dispatcher, dispatcher)
+
+        session.execute(MangaSessionCommand.Open("book-a", true, false))
+        advanceUntilIdle()
+        val revision = session.state.value.revision
+
+        session.execute(MangaSessionCommand.PromoteVisibleChapter(99, 0))
+        advanceUntilIdle()
+        assertEquals(revision, session.state.value.revision)
+        session.close()
+    }
+
+    private class FakeGateway : MangaReaderDataGateway {
+        val presentation = MutableStateFlow(MangaBookPresentation(null, null))
+        private val requests = mutableMapOf<Pair<String, Int>, CompletableDeferred<MangaChapterContent>>()
+        val persisted = mutableListOf<Triple<String, Int, Int>>()
+        val paused = mutableListOf<String>()
+        val prefetched = mutableListOf<Pair<String, Int>>()
+        val loadedRequests = mutableListOf<Pair<String, Int>>()
+
+        override fun observeBookPresentation(bookUrl: String) = presentation
+
+        override suspend fun openBook(
+            bookUrl: String?,
+            inBookshelf: Boolean,
+            chapterChanged: Boolean,
+        ) = OpenedMangaBook(
+            book = MangaBookState(
+                bookUrl = requireNotNull(bookUrl),
+                name = bookUrl,
+                author = "author",
+                coverUrl = null,
+                customCoverUrl = null,
+                sourceOrigin = "source",
+                sourceName = "source",
+                sourceType = 0,
+                inBookshelf = inBookshelf,
+                scrollMode = null,
+                sidePaddingDp = null,
+                chapterTitles = List(5) { "Chapter ${it + 1}" },
+            ),
+            chapterIndex = 0,
+            pageIndex = 0,
+            chapterCount = 5,
+        )
+
+        override suspend fun loadChapter(bookUrl: String, chapterIndex: Int): MangaChapterContent {
+            loadedRequests += bookUrl to chapterIndex
+            return requests.getOrPut(bookUrl to chapterIndex) { CompletableDeferred() }.await()
+        }
+
+        override suspend fun prefetchChapter(bookUrl: String, chapterIndex: Int) {
+            prefetched += bookUrl to chapterIndex
+        }
+
+        fun complete(bookUrl: String, chapterIndex: Int) {
+            deferred(bookUrl, chapterIndex).complete(chapter(bookUrl, chapterIndex))
+        }
+
+        fun fail(bookUrl: String, chapterIndex: Int) {
+            deferred(bookUrl, chapterIndex).completeExceptionally(IllegalStateException("failed"))
+            requests.remove(bookUrl to chapterIndex)
+        }
+
+        private fun deferred(bookUrl: String, chapterIndex: Int) =
+            requests.getOrPut(bookUrl to chapterIndex) { CompletableDeferred() }
+
+        private fun chapter(bookUrl: String, chapterIndex: Int) = MangaChapterContent(
+            chapterIndex = chapterIndex,
+            chapterTitle = "chapter $chapterIndex",
+            chapterUrl = null,
+            pages = listOf(MangaPageContent("$bookUrl/$chapterIndex", 0, 1)),
+            isVolume = false,
+        )
+
+        override suspend fun persistProgress(bookUrl: String, chapterIndex: Int, pageIndex: Int) {
+            persisted += Triple(bookUrl, chapterIndex, pageIndex)
+        }
+
+        override suspend fun applyProgress(bookUrl: String, progress: MangaProgressState) = Unit
+
+        override suspend fun resume(bookUrl: String) = Unit
+
+        override suspend fun pause(bookUrl: String, inBookshelf: Boolean) {
+            paused += bookUrl
+        }
+
+        override suspend fun syncProgress(bookUrl: String): MangaProgressState? = null
+    }
+}

+ 112 - 1
app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt

@@ -2,6 +2,8 @@ package io.legado.app.ui.book.manga
 
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.unit.IntSize
+import io.legado.app.ui.book.manga.config.MangaDoublePageMode
+import io.legado.app.ui.book.manga.config.MangaZoomStartPosition
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNull
@@ -10,6 +12,17 @@ import org.junit.Test
 
 class MangaReaderInteractionTest {
 
+    private fun page(index: Int, chapter: Int = 0) = MangaReaderItemUi.Page(
+        key = "p$index",
+        imageUrl = "url$index",
+        bookUrl = "book",
+        chapterIndex = chapter,
+        chapterCount = 2,
+        pageIndex = index,
+        pageCount = 10,
+        chapterName = "chapter",
+    )
+
     @Test
     fun `nine grid maps every cell to its configured index`() {
         val expected = (0..8).toList()
@@ -37,12 +50,23 @@ class MangaReaderInteractionTest {
         assertEquals(8, mangaClickRegionIndex(920f, 1820f, 900, 1800))
     }
 
+    @Test
+    fun `nine grid resolves the configured action using viewport coordinates`() {
+        val actions = listOf(-1, 0, 3, 2, 0, 1, 4, 1, 2)
+
+        assertEquals(3, mangaClickActionAt(actions, 750f, 300f, 900, 1800))
+        assertEquals(2, mangaClickActionAt(actions, 150f, 900f, 900, 1800))
+        assertEquals(4, mangaClickActionAt(actions, 150f, 1500f, 900, 1800))
+    }
+
     @Test
     fun `click action cycles through chapter menu and page actions`() {
         assertEquals(0, nextMangaClickAction(-1))
         assertEquals(1, nextMangaClickAction(0))
         assertEquals(2, nextMangaClickAction(1))
-        assertEquals(-1, nextMangaClickAction(2))
+        assertEquals(3, nextMangaClickAction(2))
+        assertEquals(4, nextMangaClickAction(3))
+        assertEquals(-1, nextMangaClickAction(4))
     }
 
     @Test
@@ -186,4 +210,91 @@ class MangaReaderInteractionTest {
             ),
         )
     }
+
+    @Test
+    fun `explicit chapter navigation overrides the retained previous page anchor`() {
+        assertTrue(
+            shouldForceMangaChapterPosition(
+                hasPages = true,
+                isLoading = false,
+                currentBookUrl = "book",
+                targetBookUrl = "book",
+                pendingExplicitChapterIndex = 15,
+                targetChapterIndex = 15,
+            )
+        )
+        assertFalse(
+            shouldForceMangaChapterPosition(
+                hasPages = true,
+                isLoading = false,
+                currentBookUrl = "book",
+                targetBookUrl = "book",
+                pendingExplicitChapterIndex = null,
+                targetChapterIndex = 15,
+            )
+        )
+    }
+
+    @Test
+    fun `double page spreads never pair across chapter boundaries`() {
+        val items = listOf(page(0), page(1), page(2), page(0, 1), page(1, 1))
+        assertEquals(
+            listOf(listOf(0, 1), listOf(2), listOf(3, 4)),
+            buildMangaSpreads(items, doublePage = true).map { it.itemIndices },
+        )
+    }
+
+    @Test
+    fun `chapter edge stays on its own spread`() {
+        val items = listOf(
+            page(0),
+            MangaReaderItemUi.ChapterEdge("edge", "next"),
+            page(1),
+        )
+        assertEquals(
+            listOf(listOf(0), listOf(1), listOf(2)),
+            buildMangaSpreads(items, true).map { it.itemIndices },
+        )
+    }
+
+    @Test
+    fun `wide pages stay on their own double page spread`() {
+        val items = listOf(page(0), page(1), page(2), page(3))
+        assertEquals(
+            listOf(listOf(0), listOf(1, 2), listOf(3)),
+            buildMangaSpreads(
+                items = items,
+                doublePage = true,
+                aspectRatios = mapOf("p0" to 1.5f, "p3" to 2f),
+            ).map { it.itemIndices },
+        )
+    }
+
+    @Test
+    fun `spread identity follows its page composition`() {
+        val items = listOf(page(0), page(1))
+        val paired = buildMangaSpreads(items, doublePage = true).single()
+        val separated = buildMangaSpreads(
+            items,
+            doublePage = true,
+            aspectRatios = mapOf("p0" to 2f),
+        )
+        assertEquals(listOf(0, 1), paired.itemIndices)
+        assertEquals(listOf(listOf(0), listOf(1)), separated.map { it.itemIndices })
+        assertTrue(paired.key.contains("p0"))
+        assertTrue(paired.key.contains("p1"))
+    }
+
+    @Test
+    fun `automatic zoom starts on reading direction side`() {
+        val size = IntSize(100, 200)
+        assertEquals(Offset(50f, 0f), zoomStartOffset(MangaZoomStartPosition.AUTOMATIC, size, 2f, false))
+        assertEquals(Offset(-50f, 0f), zoomStartOffset(MangaZoomStartPosition.AUTOMATIC, size, 2f, true))
+    }
+
+    @Test
+    fun `landscape double page only activates for wide viewport`() {
+        assertTrue(isDoublePageActive(MangaDoublePageMode.LANDSCAPE, IntSize(1000, 600)))
+        assertFalse(isDoublePageActive(MangaDoublePageMode.LANDSCAPE, IntSize(600, 1000)))
+    }
 }

+ 3 - 0
gradle/libs.versions.toml

@@ -90,6 +90,7 @@ biometricKtx = "1.1.0"
 reorderable = "3.1.0"
 haze = "1.7.2"
 timber = "5.0.1"
+telephoto = "0.19.0"
 miuix = "0.9.3"
 backdrop = "2.0.0"
 capsule = "2.1.3"
@@ -147,6 +148,7 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCo
 coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilCompose" }
 coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
 coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" }
+telephoto-zoomable-image-coil3 = { module = "me.saket.telephoto:zoomable-image-coil3", version.ref = "telephoto" }
 core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
 
 androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }
@@ -227,6 +229,7 @@ intellij-markdown = { module = "org.jetbrains:markdown-jvm", version.ref = "inte
 junit = { module = "junit:junit", version = "4.13.2" }
 kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
 kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
 kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
 kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
 

Некоторые файлы не были показаны из-за большого количества измененных файлов