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

refactor: migrate manga reader to Compose

HapeLee 3 месяцев назад
Родитель
Сommit
adaa0db1f8
48 измененных файлов с 2677 добавлено и 4957 удалено
  1. 4 5
      app/src/main/java/io/legado/app/data/model/MangaFooterConfig.kt
  2. 464 0
      app/src/main/java/io/legado/app/data/repository/MangaReaderSessionRepository.kt
  3. 4 2
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 7 7
      app/src/main/java/io/legado/app/model/ReadManga.kt
  5. 1 1
      app/src/main/java/io/legado/app/model/manga/BaseMangaPage.kt
  6. 2 2
      app/src/main/java/io/legado/app/model/manga/MangaChapter.kt
  7. 8 0
      app/src/main/java/io/legado/app/model/manga/MangaContent.kt
  8. 10 0
      app/src/main/java/io/legado/app/model/manga/MangaPage.kt
  9. 3 6
      app/src/main/java/io/legado/app/model/manga/ReaderLoading.kt
  10. 41 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaCoilTransformations.kt
  11. 223 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt
  12. 29 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderInteraction.kt
  13. 197 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt
  14. 488 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt
  15. 228 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt
  16. 671 0
      app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt
  17. 107 1070
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  18. 0 475
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  19. 0 55
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaAutoReadDialog.kt
  20. 0 168
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
  21. 0 132
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaColorFilterDialog.kt
  22. 0 322
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaFooterSettingDialog.kt
  23. 0 16
      app/src/main/java/io/legado/app/ui/book/manga/config/MangaScrollMode.kt
  24. 0 79
      app/src/main/java/io/legado/app/ui/book/manga/entities/EpaperTransformation.kt
  25. 0 57
      app/src/main/java/io/legado/app/ui/book/manga/entities/GrayscaleTransformation.kt
  26. 0 8
      app/src/main/java/io/legado/app/ui/book/manga/entities/MangaContent.kt
  27. 0 10
      app/src/main/java/io/legado/app/ui/book/manga/entities/MangaPage.kt
  28. 0 64
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/GestureDetectorWithLongTap.kt
  29. 0 293
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaAdapter.kt
  30. 0 17
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaLayoutManager.kt
  31. 0 141
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaVH.kt
  32. 0 76
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/ScrollTimer.kt
  33. 0 221
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonFrame.kt
  34. 0 379
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonRecyclerView.kt
  35. 0 342
      app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
  36. 0 95
      app/src/main/res/layout/activity_manga.xml
  37. 0 63
      app/src/main/res/layout/dialog_manga_auto_read.xml
  38. 0 153
      app/src/main/res/layout/dialog_manga_color_filter.xml
  39. 0 29
      app/src/main/res/layout/dialog_manga_epaper.xml
  40. 0 328
      app/src/main/res/layout/dialog_manga_footer_setting.xml
  41. 0 38
      app/src/main/res/layout/item_book_manga_edge.xml
  42. 0 66
      app/src/main/res/layout/item_book_manga_page.xml
  43. 0 208
      app/src/main/res/layout/view_manga_menu.xml
  44. 0 28
      app/src/main/res/menu/book_manga.xml
  45. 67 0
      app/src/main/res/values-zh-rCN/strings.xml
  46. 67 0
      app/src/main/res/values/strings.xml
  47. 56 0
      app/src/test/java/io/legado/app/ui/book/manga/MangaReaderInteractionTest.kt
  48. 0 1
      build.gradle.kts

+ 4 - 5
app/src/main/java/io/legado/app/ui/book/manga/entities/MangaFooterConfig.kt → app/src/main/java/io/legado/app/data/model/MangaFooterConfig.kt

@@ -1,7 +1,6 @@
-package io.legado.app.ui.book.manga.entities
+package io.legado.app.data.model
 
 import androidx.annotation.Keep
-import io.legado.app.ui.widget.ReaderInfoBarView
 
 @Keep
 data class MangaFooterConfig(
@@ -11,7 +10,7 @@ data class MangaFooterConfig(
     var hidePageNumber: Boolean = false,
     var hideProgressRatioLabel: Boolean = false,
     var hideProgressRatio: Boolean = false,
-    var footerOrientation: Int = ReaderInfoBarView.Companion.ALIGN_LEFT,//默认靠左
+    var footerOrientation: Int = 0,
     var hideFooter: Boolean = false,
-    var hideChapterName:Boolean=false,
-)
+    var hideChapterName: Boolean = false,
+)

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

@@ -0,0 +1,464 @@
+package io.legado.app.data.repository
+
+import android.app.Application
+import android.graphics.Bitmap
+import androidx.core.graphics.drawable.toBitmap
+import coil.ImageLoader
+import coil.request.ImageRequest
+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.exception.NoStackTraceException
+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.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.manga.MangaContent
+import io.legado.app.model.analyzeRule.AnalyzeRule
+import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
+import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
+import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.webBook.WebBook
+import io.legado.app.utils.ImageSaveUtils
+import io.legado.app.utils.isAbsUrl
+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.catch
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+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 object ContentUpdated : 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/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 engineCallback = object : ReadManga.Callback {
+        override fun upContent() {
+            _events.tryEmit(MangaReaderSessionEvent.ContentUpdated)
+        }
+
+        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 content(): MangaContent = ReadManga.mangaContents
+
+    fun currentBookName(): String = ReadManga.book?.name.orEmpty()
+
+    fun currentBookAuthor(): String = ReadManga.book?.author.orEmpty()
+
+    fun currentBookUrl(): String = ReadManga.book?.bookUrl.orEmpty()
+
+    fun currentBook(): Book? = ReadManga.book
+
+    fun currentChapterName(): String = ReadManga.book?.durChapterTitle.orEmpty()
+
+    fun currentSourceName(): String = ReadManga.bookSource?.bookSourceName.orEmpty()
+
+    fun currentSourceOrigin(): String? = ReadManga.book?.origin
+
+    fun currentSourceUrl(): String? = ReadManga.bookSource?.bookSourceUrl
+
+    fun currentSourceType(): Int? = ReadManga.bookSource?.getSourceType()
+
+    fun currentChapterUrl(): String? = ReadManga.curMangaChapter?.chapter?.url
+
+    fun currentChapterIndex(): Int = ReadManga.durChapterIndex
+
+    fun chapterCount(): Int = ReadManga.simulatedChapterSize
+
+    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) {
+        when {
+            ReadManga.durChapterIndex < chapterIndex -> ReadManga.moveToNextChapter()
+            ReadManga.durChapterIndex > chapterIndex -> ReadManga.moveToPrevChapter()
+        }
+        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)
+    }
+
+    fun openChapter(index: Int, pageIndex: Int = 0) {
+        if (index >= ReadManga.chapterSize) return
+        ReadManga.showLoading()
+        ReadManga.durChapterIndex = index
+        ReadManga.durChapterPos = pageIndex
+        ReadManga.saveRead()
+        ReadManga.loadContent()
+    }
+
+    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.drawable).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
+
+}

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

@@ -63,6 +63,7 @@ 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.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -231,7 +232,7 @@ import io.legado.app.ui.book.knowledge.BookEventListViewModel
 import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
 import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
 import io.legado.app.ui.book.manage.BookshelfManageScreenViewModel
-import io.legado.app.ui.book.manga.ReadMangaViewModel
+import io.legado.app.ui.book.manga.MangaReaderViewModel
 import io.legado.app.ui.book.read.ReadBookViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordOverviewViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
@@ -373,6 +374,7 @@ val appModule = module {
     singleOf(::ReadStyleConfigStore)
     singleOf(::ReadBookStyleConfigRepository)
     single<ReadStyleGateway> { get<ReadBookStyleConfigRepository>() }
+    singleOf(::MangaReaderSessionRepository)
     singleOf(::ExploreBooksUseCase)
     singleOf(::ExploreKindUiUseCase)
     singleOf(::SaveSearchBooksUseCase)
@@ -654,7 +656,7 @@ val appModule = module {
             bookKnowledgeGateway = get(),
         )
     }
-    viewModelOf(::ReadMangaViewModel)
+    viewModelOf(::MangaReaderViewModel)
     viewModel {
         ReadBookViewModel(
             application = get(),

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

@@ -23,11 +23,11 @@ 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.ui.book.manga.entities.BaseMangaPage
-import io.legado.app.ui.book.manga.entities.MangaChapter
-import io.legado.app.ui.book.manga.entities.MangaContent
-import io.legado.app.ui.book.manga.entities.MangaPage
-import io.legado.app.ui.book.manga.entities.ReaderLoading
+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
@@ -715,9 +715,9 @@ object ReadManga : CoroutineScope by MainScope() , KoinComponent{
                 MangaPage(
                     chapterIndex = chapter.index,
                     chapterSize = chapterSize,
-                    mImageUrl = src,
+                    imageUrl = src,
                     index = index,
-                    mChapterName = chapter.title
+                    chapterName = chapter.title
                 )
             }.toList()
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/manga/entities/BaseMangaPage.kt → app/src/main/java/io/legado/app/model/manga/BaseMangaPage.kt

@@ -1,4 +1,4 @@
-package io.legado.app.ui.book.manga.entities
+package io.legado.app.model.manga
 
 interface BaseMangaPage {
     val chapterIndex: Int

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/manga/entities/MangaChapter.kt → app/src/main/java/io/legado/app/model/manga/MangaChapter.kt

@@ -1,9 +1,9 @@
-package io.legado.app.ui.book.manga.entities
+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
+    val imageCount: Int,
 )

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

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

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

@@ -0,0 +1,10 @@
+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

+ 3 - 6
app/src/main/java/io/legado/app/ui/book/manga/entities/ReaderLoading.kt → app/src/main/java/io/legado/app/model/manga/ReaderLoading.kt

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

+ 41 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaCoilTransformations.kt

@@ -0,0 +1,41 @@
+package io.legado.app.ui.book.manga
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.ColorMatrix
+import android.graphics.ColorMatrixColorFilter
+import android.graphics.Paint
+import coil.size.Size
+import coil.transform.Transformation
+
+internal object MangaGrayscaleTransformation : Transformation {
+    override val cacheKey: String = "manga-grayscale-v1"
+
+    override suspend fun transform(input: Bitmap, size: Size): Bitmap {
+        return input.filteredBitmap(ColorMatrix().apply { setSaturation(0f) })
+    }
+}
+
+internal class MangaEInkTransformation(private val threshold: Int) : Transformation {
+    override val cacheKey: String = "manga-eink-v1-$threshold"
+
+    override suspend fun transform(input: Bitmap, size: Size): Bitmap {
+        val result = input.filteredBitmap(ColorMatrix().apply { setSaturation(0f) })
+        val pixels = IntArray(result.width * result.height)
+        result.getPixels(pixels, 0, result.width, 0, 0, result.width, result.height)
+        pixels.indices.forEach { index ->
+            pixels[index] = if (Color.red(pixels[index]) < threshold) Color.BLACK else Color.WHITE
+        }
+        result.setPixels(pixels, 0, result.width, 0, 0, result.width, result.height)
+        return result
+    }
+}
+
+private fun Bitmap.filteredBitmap(matrix: ColorMatrix): Bitmap {
+    val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+    Canvas(output).drawBitmap(this, 0f, 0f, Paint(Paint.ANTI_ALIAS_FLAG).apply {
+        colorFilter = ColorMatrixColorFilter(matrix)
+    })
+    return output
+}

+ 223 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -0,0 +1,223 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.runtime.Stable
+import androidx.compose.ui.graphics.Color
+import io.legado.app.data.entities.BookProgress
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.ui.book.manga.config.MangaScrollMode
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class MangaReaderUiState(
+    val bookName: String = "",
+    val bookAuthor: String = "",
+    val bookUrl: String = "",
+    val chapterName: String = "",
+    val chapterUrl: String? = null,
+    val sourceName: String = "",
+    val sourceUrl: String? = null,
+    val sourceType: Int? = null,
+    val changeSourceBook: Book? = null,
+    val pages: ImmutableList<MangaReaderItemUi> = persistentListOf(),
+    val currentItemIndex: Int = 0,
+    val currentPage: Int = 0,
+    val pageCount: Int = 0,
+    val chapterIndex: Int = 0,
+    val chapterCount: Int = 0,
+    val isLoading: Boolean = true,
+    val errorMessage: String? = null,
+    val menuVisible: Boolean = false,
+    val autoReadEnabled: Boolean = false,
+    val activeSheet: MangaReaderSheet? = null,
+    val activeDialog: MangaReaderDialog? = null,
+    val inBookshelf: Boolean = true,
+    val confirmAddToShelf: Boolean = true,
+    val scrollRequest: MangaScrollRequest? = null,
+    val settings: MangaReaderSettings = MangaReaderSettings(),
+)
+
+@Stable
+sealed interface MangaReaderItemUi {
+    val key: String
+
+    @Stable
+    data class Page(
+        override val key: String,
+        val imageUrl: String,
+        val chapterIndex: Int,
+        val chapterCount: Int,
+        val pageIndex: Int,
+        val pageCount: Int,
+        val chapterName: String,
+    ) : MangaReaderItemUi
+
+    @Stable
+    data class ChapterEdge(
+        override val key: String,
+        val message: String,
+    ) : MangaReaderItemUi
+}
+
+@Stable
+data class MangaReaderSettings(
+    val scrollMode: Int = MangaScrollMode.WEBTOON,
+    val sidePaddingPercent: Int = 0,
+    val backgroundColor: Color = Color.Black,
+    val disableScale: Boolean = true,
+    val disableScrollAnimation: Boolean = false,
+    val disableCrossFade: Boolean = false,
+    val disableClickScroll: Boolean = false,
+    val longPressEnabled: Boolean = true,
+    val preDownloadCount: Int = 10,
+    val autoReadSpeed: Int = 3,
+    val volumeKeyPage: Boolean = false,
+    val reverseVolumeKeyPage: Boolean = false,
+    val hideMangaTitle: Boolean = false,
+    val autoBrightness: Boolean = true,
+    val brightness: Int = 0,
+    val enableGray: Boolean = false,
+    val enableEInk: Boolean = false,
+    val eInkThreshold: Int = 150,
+    val filterRed: Int = 0,
+    val filterGreen: Int = 0,
+    val filterBlue: Int = 0,
+    val filterAlpha: Int = 0,
+    val hideFooter: Boolean = false,
+    val hideChapterName: Boolean = false,
+    val hidePageNumber: Boolean = false,
+    val hidePageNumberLabel: Boolean = false,
+    val hideChapter: Boolean = false,
+    val hideChapterLabel: Boolean = false,
+    val hideProgress: Boolean = false,
+    val hideProgressLabel: Boolean = false,
+    val footerAlignment: Int = 0,
+    val sourceOrigin: String? = null,
+    val clickActions: ImmutableList<Int> = persistentListOf(-1, -1, 1, 2, 0, 1, 2, 1, 1),
+)
+
+@Stable
+data class MangaScrollRequest(val id: Long, val itemIndex: Int, val animated: Boolean)
+
+sealed interface MangaReaderIntent {
+    data class Initialize(
+        val bookUrl: String?,
+        val inBookshelf: Boolean,
+        val chapterChanged: Boolean,
+    ) : MangaReaderIntent
+    data object ResumeSession : MangaReaderIntent
+    data object PauseSession : MangaReaderIntent
+    data object NetworkAvailable : MangaReaderIntent
+    data object RefreshBookSource : MangaReaderIntent
+    data object ReloadContent : MangaReaderIntent
+    data class ApplyReadingProgress(val progress: BookProgress) : MangaReaderIntent
+    data class OpenChapter(val chapterIndex: Int, val pageIndex: Int) : MangaReaderIntent
+    data class ChangeSourceBook(val book: Book, val toc: List<BookChapter>) : MangaReaderIntent
+    data class AddExternalBookToShelf(val book: Book, val toc: List<BookChapter>) : MangaReaderIntent
+    data object AddCurrentBookToShelf : MangaReaderIntent
+    data object DiscardCurrentBookAndExit : MangaReaderIntent
+    data object DismissDialog : MangaReaderIntent
+    data object DisableCurrentSource : MangaReaderIntent
+    data object RequestPayCurrentChapter : MangaReaderIntent
+    data object PayCurrentChapter : MangaReaderIntent
+    data object OpenSourceLogin : MangaReaderIntent
+    data object OpenSourceEdit : MangaReaderIntent
+    data object BackPressed : MangaReaderIntent
+    data object ToggleMenu : MangaReaderIntent
+    data object HideMenu : MangaReaderIntent
+    data object Retry : MangaReaderIntent
+    data object PreviousChapter : MangaReaderIntent
+    data object NextChapter : MangaReaderIntent
+    data object OpenCatalog : MangaReaderIntent
+    data object OpenBookInfo : MangaReaderIntent
+    data object OpenChapterUrl : MangaReaderIntent
+    data object ChangeSource : MangaReaderIntent
+    data object RefreshChapter : MangaReaderIntent
+    data object OpenReaderSettings : MangaReaderIntent
+    data object OpenAutoReadSettings : MangaReaderIntent
+    data object OpenColorFilter : MangaReaderIntent
+    data object OpenClickSettings : MangaReaderIntent
+    data object OpenPreDownloadSettings : MangaReaderIntent
+    data object OpenSourceActions : MangaReaderIntent
+    data object ToggleAutoRead : MangaReaderIntent
+    data object DismissSheet : MangaReaderIntent
+    data class UpdateSetting(
+        val key: MangaReaderSettingKey,
+        val value: Int,
+    ) : MangaReaderIntent
+    data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
+    data class PageStep(val direction: Int) : MangaReaderIntent
+    data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
+    data class VisibleItemChanged(val itemIndex: Int) : MangaReaderIntent
+    data class LongPressPage(val imageUrl: String) : MangaReaderIntent
+}
+
+sealed interface MangaReaderSheet {
+    data object Reader : MangaReaderSheet
+    data object AutoRead : MangaReaderSheet
+    data object ColorFilter : MangaReaderSheet
+    data object ClickActions : MangaReaderSheet
+    data object ChangeSource : MangaReaderSheet
+    data object SourceActions : MangaReaderSheet
+}
+
+sealed interface MangaReaderDialog {
+    data object AddToShelf : MangaReaderDialog
+    data class ConfirmPay(val chapterName: String) : MangaReaderDialog
+    data class ConfirmProgress(val progress: BookProgress) : MangaReaderDialog
+}
+
+enum class MangaReaderSettingKey {
+    SCROLL_MODE,
+    SIDE_PADDING,
+    BACKGROUND_RED,
+    BACKGROUND_GREEN,
+    BACKGROUND_BLUE,
+    DISABLE_SCALE,
+    DISABLE_SCROLL_ANIMATION,
+    DISABLE_CROSS_FADE,
+    DISABLE_CLICK_SCROLL,
+    LONG_PRESS,
+    PRE_DOWNLOAD,
+    AUTO_READ_SPEED,
+    VOLUME_KEY_PAGE,
+    REVERSE_VOLUME_KEY_PAGE,
+    HIDE_MANGA_TITLE,
+    ENABLE_GRAY,
+    ENABLE_EINK,
+    EINK_THRESHOLD,
+    FILTER_RED,
+    FILTER_GREEN,
+    FILTER_BLUE,
+    FILTER_ALPHA,
+    AUTO_BRIGHTNESS,
+    BRIGHTNESS,
+    HIDE_FOOTER,
+    HIDE_CHAPTER_NAME,
+    HIDE_PAGE_NUMBER,
+    HIDE_PAGE_NUMBER_LABEL,
+    HIDE_CHAPTER,
+    HIDE_CHAPTER_LABEL,
+    HIDE_PROGRESS,
+    HIDE_PROGRESS_LABEL,
+    FOOTER_ALIGNMENT,
+}
+
+sealed interface MangaReaderEffect {
+    data class Finish(val bookshelfChanged: Boolean = false) : MangaReaderEffect
+    data object OpenCatalog : MangaReaderEffect
+    data object OpenBookInfo : MangaReaderEffect
+    data class OpenChapterUrl(val externalBrowser: Boolean) : MangaReaderEffect
+    data class OpenSourceLogin(val sourceUrl: String) : MangaReaderEffect
+    data class OpenSourceEdit(val sourceUrl: String) : MangaReaderEffect
+    data class ShowMessage(val message: String) : MangaReaderEffect
+    data class OpenPaymentUrl(
+        val url: String,
+        val sourceOrigin: String?,
+        val sourceName: String?,
+        val sourceType: Int?,
+    ) : MangaReaderEffect
+    data class SetWindowBrightness(val auto: Boolean, val brightness: Int) : MangaReaderEffect
+    data class SetSystemBarsVisible(val visible: Boolean) : MangaReaderEffect
+}

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

@@ -0,0 +1,29 @@
+package io.legado.app.ui.book.manga
+
+internal fun mangaClickRegionIndex(
+    x: Float,
+    y: Float,
+    width: Int,
+    height: Int,
+): Int {
+    val column = (x / (width.coerceAtLeast(1) / 3f)).toInt().coerceIn(0, 2)
+    val row = (y / (height.coerceAtLeast(1) / 3f)).toInt().coerceIn(0, 2)
+    return row * 3 + column
+}
+
+internal fun nextMangaClickAction(action: Int): Int = when (action) {
+    -1 -> 0
+    0 -> 1
+    1 -> 2
+    else -> -1
+}
+
+internal fun mangaPageStepTarget(
+    currentIndex: Int,
+    itemCount: Int,
+    direction: Int,
+): Int? {
+    if (itemCount <= 0) return null
+    val target = (currentIndex + direction).coerceIn(0, itemCount - 1)
+    return target.takeIf { it != currentIndex }
+}

+ 197 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -0,0 +1,197 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+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.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.statusBarsPadding
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+
+@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 progress = if (page.chapterCount <= 0 || page.pageCount <= 0) 0.0 else {
+        (page.chapterIndex.toDouble() + (page.pageIndex + 1.0) / page.pageCount) / page.chapterCount
+    }
+    val pageLabel = stringResource(R.string.manga_reader_page_label)
+    val chapterLabel = stringResource(R.string.manga_reader_chapter_label)
+    val progressLabel = stringResource(R.string.manga_reader_progress_label)
+    val text = buildString {
+        if (!settings.hideChapterName) append(page.chapterName).append(' ')
+        if (!settings.hidePageNumber) {
+            if (!settings.hidePageNumberLabel) append(pageLabel).append(' ')
+            append("${page.pageIndex + 1}/${page.pageCount} ")
+        }
+        if (!settings.hideChapter) {
+            if (!settings.hideChapterLabel) append(chapterLabel).append(' ')
+            append("${page.chapterIndex + 1}/${page.chapterCount} ")
+        }
+        if (!settings.hideProgress) {
+            if (!settings.hideProgressLabel) append(progressLabel).append(' ')
+            append("%.1f%%".format((progress * 100).coerceAtMost(100.0)))
+        }
+    }.trim()
+    val alignment = when (settings.footerAlignment) {
+        1 -> Alignment.BottomCenter
+        2 -> Alignment.BottomEnd
+        else -> Alignment.BottomStart
+    }
+    Text(
+        text = text,
+        color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.78f),
+        style = MaterialTheme.typography.labelSmall,
+        modifier = Modifier
+            .align(alignment)
+            .navigationBarsPadding()
+            .padding(horizontal = 12.dp, vertical = 8.dp),
+    )
+}
+
+@Composable
+internal fun BoxScope.MangaReaderMenu(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    val readingPageDescription = stringResource(R.string.manga_reader_page_semantics)
+    AnimatedVisibility(
+        visible = state.menuVisible,
+        enter = fadeIn(),
+        exit = fadeOut(),
+        modifier = Modifier.fillMaxSize(),
+    ) {
+        Box(
+            Modifier
+                .fillMaxSize()
+                .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.28f))
+                .combinedClickable(onClick = { onIntent(MangaReaderIntent.HideMenu) }),
+        )
+    }
+    AnimatedVisibility(
+        visible = state.menuVisible,
+        modifier = Modifier.align(Alignment.TopCenter),
+    ) {
+        Surface(color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), shadowElevation = 8.dp) {
+            Row(
+                modifier = Modifier.fillMaxWidth().statusBarsPadding().padding(8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                IconButton(onClick = { onIntent(MangaReaderIntent.BackPressed) }) { Text("‹") }
+                Column(modifier = Modifier.weight(1f)) {
+                    Text(
+                        state.bookName,
+                        maxLines = 1,
+                        style = MaterialTheme.typography.titleMedium,
+                        modifier = Modifier.combinedClickable(
+                            onClick = { onIntent(MangaReaderIntent.OpenBookInfo) },
+                        ),
+                    )
+                    Text(
+                        state.chapterName,
+                        maxLines = 1,
+                        style = MaterialTheme.typography.labelMedium,
+                        modifier = Modifier.combinedClickable(
+                            onClick = { onIntent(MangaReaderIntent.OpenChapterUrl) },
+                        ),
+                    )
+                }
+                TextButton(onClick = { onIntent(MangaReaderIntent.ChangeSource) }) { Text(stringResource(R.string.change_origin)) }
+                TextButton(onClick = { onIntent(MangaReaderIntent.RefreshChapter) }) { Text(stringResource(R.string.refresh)) }
+                TextButton(onClick = { onIntent(MangaReaderIntent.OpenSourceActions) }) { Text(stringResource(R.string.book_source)) }
+            }
+        }
+    }
+    AnimatedVisibility(
+        visible = state.menuVisible,
+        modifier = Modifier.align(Alignment.BottomCenter),
+    ) {
+        Surface(color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), shadowElevation = 8.dp) {
+            Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(12.dp)) {
+                if (state.pageCount > 1) {
+                    Slider(
+                        value = state.currentPage.toFloat().coerceIn(0f, (state.pageCount - 1).toFloat()),
+                        onValueChange = { onIntent(MangaReaderIntent.SeekToPage(it.toInt())) },
+                        valueRange = 0f..(state.pageCount - 1).toFloat(),
+                        steps = (state.pageCount - 2).coerceAtLeast(0),
+                        modifier = Modifier.semantics { contentDescription = readingPageDescription },
+                    )
+                }
+                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+                    ReaderMenuButton(stringResource(R.string.previous_chapter)) { onIntent(MangaReaderIntent.PreviousChapter) }
+                    ReaderMenuButton(stringResource(R.string.chapter_list)) { onIntent(MangaReaderIntent.OpenCatalog) }
+                    ReaderMenuButton(if (state.autoReadEnabled) stringResource(R.string.stop) else stringResource(R.string.manga_reader_auto_short)) {
+                        onIntent(MangaReaderIntent.ToggleAutoRead)
+                    }
+                    ReaderMenuButton(stringResource(R.string.next_chapter)) { onIntent(MangaReaderIntent.NextChapter) }
+                }
+                Spacer(Modifier.height(6.dp))
+                Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+                    ReaderMenuButton(stringResource(R.string.manga_reader_page_settings)) { onIntent(MangaReaderIntent.OpenAutoReadSettings) }
+                    ReaderMenuButton(stringResource(R.string.footer)) { onIntent(MangaReaderIntent.OpenReaderSettings) }
+                    ReaderMenuButton(stringResource(R.string.manga_reader_filter_short)) { onIntent(MangaReaderIntent.OpenColorFilter) }
+                    ReaderMenuButton(stringResource(R.string.manga_reader_click_area_short)) { onIntent(MangaReaderIntent.OpenClickSettings) }
+                    ReaderMenuButton(stringResource(R.string.manga_reader_preload_short)) { onIntent(MangaReaderIntent.OpenPreDownloadSettings) }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ReaderMenuButton(text: String, onClick: () -> Unit) {
+    TextButton(onClick = onClick) { Text(text) }
+}
+
+@Composable
+internal fun BoxScope.ReaderStatusOverlay(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    if (state.isLoading) {
+        Surface(
+            modifier = Modifier.fillMaxSize(),
+            color = MaterialTheme.colorScheme.surface,
+        ) {
+            Box(contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+        }
+    }
+    state.errorMessage?.let { message ->
+        Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
+            Column(
+                horizontalAlignment = Alignment.CenterHorizontally,
+                verticalArrangement = Arrangement.Center,
+            ) {
+                Text(message, modifier = Modifier.padding(24.dp))
+                Button(onClick = { onIntent(MangaReaderIntent.Retry) }) { Text(stringResource(R.string.retry)) }
+            }
+        }
+    }
+}

+ 488 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -0,0 +1,488 @@
+package io.legado.app.ui.book.manga
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.gestures.rememberTransformableState
+import androidx.compose.foundation.gestures.transformable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.isSpecified
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ColorMatrix
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.layout.onSizeChanged
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import coil.request.ImageRequest
+import coil.request.Parameters
+import io.legado.app.ui.book.manga.config.MangaScrollMode
+import io.legado.app.R
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.distinctUntilChanged
+import org.koin.compose.koinInject
+import kotlin.math.ceil
+
+private val LocalReaderViewportSize = staticCompositionLocalOf { IntSize.Zero }
+private val LocalMangaAspectRatios = staticCompositionLocalOf<MutableMap<String, Float>> {
+    mutableMapOf()
+}
+
+@Composable
+fun MangaReaderScreen(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    modifier: Modifier = Modifier,
+    imageLoader: ImageLoader = koinInject(),
+) {
+    BackHandler { onIntent(MangaReaderIntent.BackPressed) }
+    var viewportSize by remember { mutableStateOf(IntSize.Zero) }
+    val aspectRatios = remember { mutableStateMapOf<String, Float>() }
+
+    LaunchedEffect(
+        state.autoReadEnabled,
+        state.settings.autoReadSpeed,
+        state.menuVisible,
+        state.activeSheet,
+    ) {
+        val isWebtoon = state.settings.scrollMode == MangaScrollMode.WEBTOON ||
+                state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
+        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null || isWebtoon) {
+            return@LaunchedEffect
+        }
+        while (true) {
+            delay(state.settings.autoReadSpeed.coerceAtLeast(1) * 1_000L)
+            onIntent(MangaReaderIntent.PageStep(1))
+        }
+    }
+
+    val context = LocalContext.current
+    LaunchedEffect(state.currentItemIndex, state.settings.preDownloadCount, state.pages) {
+        val preloadCount = state.settings.preDownloadCount.coerceAtLeast(0)
+        val start = (state.currentItemIndex - preloadCount).coerceAtLeast(0)
+        val end = (state.currentItemIndex + preloadCount + 1)
+            .coerceAtMost(state.pages.size)
+        state.pages.subList(start.coerceAtMost(end), end)
+            .filterIsInstance<MangaReaderItemUi.Page>()
+            .forEach { page ->
+                imageLoader.enqueue(page.imageRequest(state.settings, context) { ratio ->
+                    aspectRatios[page.key] = ratio
+                })
+            }
+    }
+
+    CompositionLocalProvider(
+        LocalReaderViewportSize provides viewportSize,
+        LocalMangaAspectRatios provides aspectRatios,
+    ) {
+        Box(
+            modifier = modifier
+                .fillMaxSize()
+                .onSizeChanged { viewportSize = it }
+                .background(state.settings.backgroundColor)
+        ) {
+            when (state.settings.scrollMode) {
+                MangaScrollMode.PAGE_LEFT_TO_RIGHT,
+                MangaScrollMode.PAGE_RIGHT_TO_LEFT -> HorizontalMangaPager(state, onIntent, imageLoader)
+                MangaScrollMode.PAGE_TOP_TO_BOTTOM -> VerticalMangaPager(state, onIntent, imageLoader)
+                else -> WebtoonMangaList(state, onIntent, imageLoader)
+            }
+
+            MangaFooter(state)
+            MangaReaderMenu(state, onIntent)
+            ReaderStatusOverlay(state, onIntent)
+        }
+    }
+    state.activeSheet?.takeIf { it != MangaReaderSheet.ChangeSource }?.let { sheet ->
+        MangaReaderSettingsSheet(sheet, state, onIntent)
+    }
+    if (state.activeSheet == MangaReaderSheet.ChangeSource) {
+        state.changeSourceBook?.let { oldBook ->
+            ChangeSourceSheet(
+                show = true,
+                oldBook = oldBook,
+                fromReadBookActivity = true,
+                allowAddAsNew = true,
+                dismissOnReplaceStart = true,
+                onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
+                onReplace = { _, book, toc, _ ->
+                    onIntent(MangaReaderIntent.DismissSheet)
+                    onIntent(MangaReaderIntent.ChangeSourceBook(book, toc))
+                },
+                onAddAsNew = { book, toc ->
+                    onIntent(MangaReaderIntent.AddExternalBookToShelf(book, toc))
+                },
+            )
+        }
+    }
+    AppAlertDialog(
+        show = state.activeDialog is MangaReaderDialog.AddToShelf,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissDialog) },
+        title = stringResource(R.string.add_to_bookshelf),
+        text = stringResource(R.string.check_add_bookshelf, state.bookName),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { onIntent(MangaReaderIntent.AddCurrentBookToShelf) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(MangaReaderIntent.DiscardCurrentBookAndExit) },
+    )
+    val payDialog = state.activeDialog as? MangaReaderDialog.ConfirmPay
+    AppAlertDialog(
+        show = payDialog != null,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissDialog) },
+        title = stringResource(R.string.chapter_pay),
+        text = payDialog?.chapterName,
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { onIntent(MangaReaderIntent.PayCurrentChapter) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(MangaReaderIntent.DismissDialog) },
+    )
+    val progressDialog = state.activeDialog as? MangaReaderDialog.ConfirmProgress
+    AppAlertDialog(
+        show = progressDialog != null,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissDialog) },
+        title = stringResource(R.string.get_book_progress),
+        text = stringResource(R.string.cloud_progress_exceeds_current),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            progressDialog?.progress?.let { onIntent(MangaReaderIntent.ApplyReadingProgress(it)) }
+            onIntent(MangaReaderIntent.DismissDialog)
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(MangaReaderIntent.DismissDialog) },
+    )
+}
+
+@Composable
+private fun WebtoonMangaList(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    imageLoader: ImageLoader,
+) {
+    val listState = rememberLazyListState(initialFirstVisibleItemIndex = state.currentItemIndex)
+    LaunchedEffect(listState, state.pages) {
+        snapshotFlow {
+            listState.layoutInfo.visibleItemsInfo.firstOrNull { visible ->
+                state.pages.getOrNull(visible.index) is MangaReaderItemUi.Page
+            }?.index
+        }
+            .distinctUntilChanged()
+            .collect { index -> index?.let { onIntent(MangaReaderIntent.VisibleItemChanged(it)) } }
+    }
+    LaunchedEffect(state.scrollRequest?.id) {
+        state.scrollRequest?.let {
+            if (it.animated) listState.animateScrollToItem(it.itemIndex)
+            else listState.scrollToItem(it.itemIndex)
+        }
+    }
+    LaunchedEffect(
+        state.autoReadEnabled,
+        state.settings.autoReadSpeed,
+        state.menuVisible,
+        state.activeSheet,
+    ) {
+        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null) return@LaunchedEffect
+        val distance = state.settings.autoReadSpeed.coerceAtLeast(1)
+        val duration = ceil(16f / distance * 10_000f).toInt()
+        while (true) {
+            val consumed = listState.animateScrollBy(
+                value = 10_000f,
+                animationSpec = tween(durationMillis = duration, easing = LinearEasing),
+            )
+            if (consumed < 1f) {
+                onIntent(MangaReaderIntent.NextChapter)
+                delay(500L)
+            }
+        }
+    }
+    val fraction = (1f - state.settings.sidePaddingPercent.coerceIn(0, 45) * 2f / 100f)
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = if (state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP) {
+            Arrangement.spacedBy(8.dp)
+        } else Arrangement.Top,
+    ) {
+        items(
+            count = state.pages.size,
+            key = { state.pages[it].key },
+            contentType = { state.pages[it]::class },
+        ) { index ->
+            MangaReaderItem(
+                item = state.pages[index],
+                settings = state.settings,
+                onIntent = onIntent,
+                imageLoader = imageLoader,
+                modifier = Modifier.fillMaxWidth(fraction),
+                paged = false,
+            )
+        }
+    }
+}
+
+@Composable
+private fun HorizontalMangaPager(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    imageLoader: ImageLoader,
+) {
+    val pagerState = rememberPagerState(
+        initialPage = state.currentItemIndex,
+        pageCount = { state.pages.size.coerceAtLeast(1) },
+    )
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }.distinctUntilChanged()
+            .collect { onIntent(MangaReaderIntent.VisibleItemChanged(it)) }
+    }
+    LaunchedEffect(state.scrollRequest?.id) {
+        state.scrollRequest?.let {
+            if (it.animated) pagerState.animateScrollToPage(it.itemIndex)
+            else pagerState.scrollToPage(it.itemIndex)
+        }
+    }
+    HorizontalPager(
+        state = pagerState,
+        reverseLayout = state.settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT,
+        modifier = Modifier.fillMaxSize(),
+    ) { page ->
+        state.pages.getOrNull(page)?.let {
+            MangaReaderItem(it, state.settings, onIntent, imageLoader, Modifier.fillMaxSize(), true)
+        }
+    }
+}
+
+@Composable
+private fun VerticalMangaPager(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+    imageLoader: ImageLoader,
+) {
+    val pagerState = rememberPagerState(
+        initialPage = state.currentItemIndex,
+        pageCount = { state.pages.size.coerceAtLeast(1) },
+    )
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }.distinctUntilChanged()
+            .collect { onIntent(MangaReaderIntent.VisibleItemChanged(it)) }
+    }
+    LaunchedEffect(state.scrollRequest?.id) {
+        state.scrollRequest?.let {
+            if (it.animated) pagerState.animateScrollToPage(it.itemIndex)
+            else pagerState.scrollToPage(it.itemIndex)
+        }
+    }
+    VerticalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
+        state.pages.getOrNull(page)?.let {
+            MangaReaderItem(it, state.settings, onIntent, imageLoader, Modifier.fillMaxSize(), true)
+        }
+    }
+}
+
+@Composable
+private fun MangaReaderItem(
+    item: MangaReaderItemUi,
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+    imageLoader: ImageLoader,
+    modifier: Modifier,
+    paged: Boolean,
+) {
+    when (item) {
+        is MangaReaderItemUi.Page -> MangaPageImage(item, settings, onIntent, imageLoader, modifier, paged)
+        is MangaReaderItemUi.ChapterEdge -> Box(
+            modifier = modifier.then(if (paged) Modifier.fillMaxHeight() else Modifier.height(96.dp)),
+            contentAlignment = Alignment.Center,
+        ) { Text(item.message, color = MaterialTheme.colorScheme.onSurfaceVariant) }
+    }
+}
+
+@Composable
+private fun MangaPageImage(
+    page: MangaReaderItemUi.Page,
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+    imageLoader: ImageLoader,
+    modifier: Modifier,
+    paged: Boolean,
+) {
+    var scale by remember(page.key) { mutableFloatStateOf(1f) }
+    var offset by remember(page.key) { mutableStateOf(Offset.Zero) }
+    var imageSize by remember(page.key) { mutableStateOf(IntSize.Zero) }
+    var positionInRoot by remember(page.key) { mutableStateOf(Offset.Zero) }
+    val viewportSize = LocalReaderViewportSize.current
+    val aspectRatios = LocalMangaAspectRatios.current
+    val context = LocalContext.current
+    val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
+    val transformState = rememberTransformableState { centroid, zoomChange, panChange, _ ->
+        val newScale = (scale * zoomChange).coerceIn(1f, 5f)
+        if (newScale == 1f) {
+            offset = Offset.Zero
+        } else {
+            val maxX = imageSize.width * (newScale - 1f) / 2f
+            val maxY = imageSize.height * (newScale - 1f) / 2f
+            val center = Offset(imageSize.width / 2f, imageSize.height / 2f)
+            val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: center
+            val transformedOffset = offset * zoomChange +
+                    (effectiveCentroid - center) * (1f - zoomChange) + panChange
+            offset = Offset(
+                x = transformedOffset.x.coerceIn(-maxX, maxX),
+                y = transformedOffset.y.coerceIn(-maxY, maxY),
+            )
+        }
+        scale = newScale
+    }
+    val transformModifier = if (settings.disableScale) Modifier else Modifier
+        .clipToBounds()
+        .graphicsLayer(scaleX = scale, scaleY = scale, translationX = offset.x, translationY = offset.y)
+        .transformable(
+            state = transformState,
+            canPan = { scale > 1f },
+            lockRotationOnZoomPan = true,
+        )
+
+    val webtoonSizeModifier = if (paged) Modifier else {
+        aspectRatios[page.key]?.takeIf { it > 0f }?.let { Modifier.aspectRatio(it) }
+            ?: Modifier.height(fallbackHeight)
+    }
+    val request = remember(page.key, settings) {
+        page.imageRequest(settings, context = context) { ratio ->
+            aspectRatios[page.key] = ratio
+        }
+    }
+
+    AsyncImage(
+        model = request,
+        imageLoader = imageLoader,
+        contentDescription = stringResource(
+            R.string.manga_reader_page_description,
+            page.chapterName,
+            page.pageIndex + 1,
+        ),
+        contentScale = if (paged) ContentScale.Fit else ContentScale.FillWidth,
+        colorFilter = mangaColorFilter(settings),
+        modifier = modifier
+            .then(webtoonSizeModifier)
+            .onSizeChanged { imageSize = it }
+            .onGloballyPositioned { positionInRoot = it.positionInRoot() }
+            .then(transformModifier)
+            .pointerInput(page.key, settings) {
+                detectTapGestures(
+                    onTap = { tap ->
+                        clickAction(
+                            settings = settings,
+                            onIntent = onIntent,
+                            offset = positionInRoot + tap,
+                            width = viewportSize.width,
+                            height = viewportSize.height,
+                        )
+                    },
+                    onDoubleTap = if (settings.disableScale) null else { _ ->
+                        scale = if (scale > 1f) 1f else 2.5f
+                        if (scale == 1f) offset = Offset.Zero
+                    },
+                    onLongPress = if (settings.longPressEnabled) { _ ->
+                        onIntent(MangaReaderIntent.LongPressPage(page.imageUrl))
+                    } else null,
+                )
+            },
+    )
+}
+
+private fun MangaReaderItemUi.Page.imageRequest(
+    settings: MangaReaderSettings,
+    context: android.content.Context,
+    onAspectRatio: (Float) -> Unit = {},
+): ImageRequest {
+    return ImageRequest.Builder(context)
+        .data(imageUrl)
+        .parameters(Parameters.Builder().apply {
+            set("manga", true)
+            settings.sourceOrigin?.let { set("sourceOrigin", it) }
+        }.build())
+        .apply {
+            when {
+                settings.enableEInk -> transformations(MangaEInkTransformation(settings.eInkThreshold))
+                settings.enableGray -> transformations(MangaGrayscaleTransformation)
+            }
+            crossfade(!settings.disableCrossFade)
+        }
+        .listener(onSuccess = { _, result ->
+            val drawable = result.drawable
+            if (drawable.intrinsicWidth > 0 && drawable.intrinsicHeight > 0) {
+                onAspectRatio(drawable.intrinsicWidth.toFloat() / drawable.intrinsicHeight)
+            }
+        })
+        .build()
+}
+
+private fun mangaColorFilter(settings: MangaReaderSettings): ColorFilter? {
+    if (settings.filterRed == 0 && settings.filterGreen == 0 &&
+        settings.filterBlue == 0 && settings.filterAlpha == 0
+    ) return null
+    return ColorFilter.colorMatrix(ColorMatrix(floatArrayOf(
+        (255 - settings.filterRed) / 255f, 0f, 0f, 0f, 0f,
+        0f, (255 - settings.filterGreen) / 255f, 0f, 0f, 0f,
+        0f, 0f, (255 - settings.filterBlue) / 255f, 0f, 0f,
+        0f, 0f, 0f, (255 - settings.filterAlpha) / 255f, 0f,
+    )))
+}
+
+private fun clickAction(
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+    offset: Offset,
+    width: Int,
+    height: Int,
+) {
+    val regionIndex = mangaClickRegionIndex(offset.x, offset.y, width, height)
+    when (settings.clickActions.getOrNull(regionIndex) ?: 0) {
+        -1 -> Unit
+        0 -> onIntent(MangaReaderIntent.ToggleMenu)
+        1 -> if (!settings.disableClickScroll) onIntent(MangaReaderIntent.PageStep(1))
+        2 -> if (!settings.disableClickScroll) onIntent(MangaReaderIntent.PageStep(-1))
+        3 -> onIntent(MangaReaderIntent.NextChapter)
+        4 -> onIntent(MangaReaderIntent.PreviousChapter)
+    }
+}
+

+ 228 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt

@@ -0,0 +1,228 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.foundation.layout.Arrangement
+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.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+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.widget.components.modalBottomSheet.AppModalBottomSheet
+import kotlin.math.roundToInt
+
+@Composable
+internal fun MangaReaderSettingsSheet(
+    sheet: MangaReaderSheet,
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    val settings = state.settings
+    AppModalBottomSheet(
+        show = true,
+        onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
+        title = when (sheet) {
+            MangaReaderSheet.Reader -> stringResource(R.string.manga_setting)
+            MangaReaderSheet.AutoRead -> stringResource(R.string.manga_reader_auto_read)
+            MangaReaderSheet.ColorFilter -> stringResource(R.string.manga_reader_display_filter)
+            MangaReaderSheet.ClickActions -> stringResource(R.string.manga_reader_click_areas)
+            MangaReaderSheet.ChangeSource -> stringResource(R.string.change_origin)
+            MangaReaderSheet.SourceActions -> stringResource(R.string.manga_reader_source_actions)
+        },
+    ) {
+        Column(
+            Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState()),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            when (sheet) {
+                MangaReaderSheet.Reader -> {
+                    Text(stringResource(R.string.read_type), style = MaterialTheme.typography.titleMedium)
+                    listOf(
+                        MangaScrollMode.WEBTOON to stringResource(R.string.webtoon),
+                        MangaScrollMode.WEBTOON_WITH_GAP to stringResource(R.string.manga_reader_webtoon_gap),
+                        MangaScrollMode.PAGE_LEFT_TO_RIGHT to stringResource(R.string.manga_reader_left_to_right),
+                        MangaScrollMode.PAGE_RIGHT_TO_LEFT to stringResource(R.string.manga_reader_right_to_left),
+                        MangaScrollMode.PAGE_TOP_TO_BOTTOM to stringResource(R.string.manga_reader_top_to_bottom),
+                    ).forEach { (mode, label) ->
+                        TextButton(onClick = {
+                            onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SCROLL_MODE, mode))
+                        }) { Text(if (settings.scrollMode == mode) "✓ $label" else label) }
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_side_padding), settings.sidePaddingPercent, 0..45) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SIDE_PADDING, it))
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_preload_pages), settings.preDownloadCount, 0..30) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.PRE_DOWNLOAD, it))
+                    }
+                    Text(stringResource(R.string.background_color), style = MaterialTheme.typography.titleMedium)
+                    SettingSlider(stringResource(R.string.manga_reader_red), (settings.backgroundColor.red * 255).roundToInt(), 0..255) {
+                        updateInt(onIntent, MangaReaderSettingKey.BACKGROUND_RED, it)
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_green), (settings.backgroundColor.green * 255).roundToInt(), 0..255) {
+                        updateInt(onIntent, MangaReaderSettingKey.BACKGROUND_GREEN, it)
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_blue), (settings.backgroundColor.blue * 255).roundToInt(), 0..255) {
+                        updateInt(onIntent, MangaReaderSettingKey.BACKGROUND_BLUE, it)
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_pinch_zoom), !settings.disableScale) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.DISABLE_SCALE, (!it).intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_tap_turn), !settings.disableClickScroll) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.DISABLE_CLICK_SCROLL, (!it).intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_scroll_animation), !settings.disableScrollAnimation) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.DISABLE_SCROLL_ANIMATION, (!it).intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_image_fade), !settings.disableCrossFade) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.DISABLE_CROSS_FADE, (!it).intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_long_press_save), settings.longPressEnabled) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.LONG_PRESS, it.intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_volume_page), settings.volumeKeyPage) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.VOLUME_KEY_PAGE, it.intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_reverse_volume), settings.reverseVolumeKeyPage) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.REVERSE_VOLUME_KEY_PAGE, it.intValue))
+                    }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_edge_prompt), settings.hideMangaTitle) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.HIDE_MANGA_TITLE, it.intValue))
+                    }
+                    Text(stringResource(R.string.footer), style = MaterialTheme.typography.titleMedium)
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_footer), settings.hideFooter) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_FOOTER, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_chapter_name), settings.hideChapterName) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_CHAPTER_NAME, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_page_number), settings.hidePageNumber) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_PAGE_NUMBER, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_page_label), settings.hidePageNumberLabel) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_PAGE_NUMBER_LABEL, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_chapter_progress), settings.hideChapter) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_CHAPTER, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_chapter_label), settings.hideChapterLabel) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_CHAPTER_LABEL, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_total_progress), settings.hideProgress) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_PROGRESS, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_hide_progress_label), settings.hideProgressLabel) { updateBoolean(onIntent, MangaReaderSettingKey.HIDE_PROGRESS_LABEL, it) }
+                    Text(stringResource(R.string.manga_reader_footer_alignment), style = MaterialTheme.typography.titleMedium)
+                    TextButton(onClick = { updateInt(onIntent, MangaReaderSettingKey.FOOTER_ALIGNMENT, 0) }) {
+                        val label = stringResource(R.string.manga_reader_left_align)
+                        Text(if (settings.footerAlignment == 0) "✓ $label" else label)
+                    }
+                    TextButton(onClick = { updateInt(onIntent, MangaReaderSettingKey.FOOTER_ALIGNMENT, 1) }) {
+                        val label = stringResource(R.string.manga_reader_center_align)
+                        Text(if (settings.footerAlignment == 1) "✓ $label" else label)
+                    }
+                }
+                MangaReaderSheet.AutoRead -> {
+                    SettingSwitch(stringResource(R.string.manga_reader_enable_auto_read), state.autoReadEnabled) {
+                        onIntent(MangaReaderIntent.ToggleAutoRead)
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_auto_speed), settings.autoReadSpeed, 1..15) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.AUTO_READ_SPEED, it))
+                    }
+                }
+                MangaReaderSheet.ColorFilter -> {
+                    SettingSwitch(stringResource(R.string.manga_reader_grayscale), settings.enableGray) { updateBoolean(onIntent, MangaReaderSettingKey.ENABLE_GRAY, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_eink), settings.enableEInk) { updateBoolean(onIntent, MangaReaderSettingKey.ENABLE_EINK, it) }
+                    SettingSlider(stringResource(R.string.manga_reader_eink_threshold), settings.eInkThreshold, 0..255) {
+                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.EINK_THRESHOLD, it))
+                    }
+                    SettingSlider(stringResource(R.string.manga_reader_filter_red), settings.filterRed, 0..255) { updateInt(onIntent, MangaReaderSettingKey.FILTER_RED, it) }
+                    SettingSlider(stringResource(R.string.manga_reader_filter_green), settings.filterGreen, 0..255) { updateInt(onIntent, MangaReaderSettingKey.FILTER_GREEN, it) }
+                    SettingSlider(stringResource(R.string.manga_reader_filter_blue), settings.filterBlue, 0..255) { updateInt(onIntent, MangaReaderSettingKey.FILTER_BLUE, it) }
+                    SettingSlider(stringResource(R.string.manga_reader_filter_alpha), settings.filterAlpha, 0..255) { updateInt(onIntent, MangaReaderSettingKey.FILTER_ALPHA, it) }
+                    SettingSwitch(stringResource(R.string.manga_reader_system_brightness), settings.autoBrightness) { updateBoolean(onIntent, MangaReaderSettingKey.AUTO_BRIGHTNESS, it) }
+                    if (!settings.autoBrightness) {
+                        SettingSlider(stringResource(R.string.manga_reader_screen_brightness), settings.brightness, 0..255) {
+                            updateInt(onIntent, MangaReaderSettingKey.BRIGHTNESS, it)
+                        }
+                    }
+                }
+                MangaReaderSheet.ClickActions -> {
+                    val labels = mapOf(
+                        -1 to stringResource(R.string.previous_chapter),
+                        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),
+                    )
+                    repeat(3) { row ->
+                        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
+                            repeat(3) { column ->
+                                val index = row * 3 + column
+                                val action = settings.clickActions.getOrElse(index) { 0 }
+                                TextButton(onClick = {
+                                    val next = nextMangaClickAction(action)
+                                    onIntent(MangaReaderIntent.UpdateClickAction(index, next))
+                                }) { Text(labels[action].orEmpty()) }
+                            }
+                        }
+                    }
+                    Text(stringResource(R.string.manga_reader_click_cycle_hint), style = MaterialTheme.typography.bodySmall)
+                }
+                MangaReaderSheet.ChangeSource -> Unit
+                MangaReaderSheet.SourceActions -> {
+                    TextButton(onClick = { onIntent(MangaReaderIntent.OpenSourceLogin) }) { Text(stringResource(R.string.login)) }
+                    TextButton(onClick = { onIntent(MangaReaderIntent.RequestPayCurrentChapter) }) { Text(stringResource(R.string.manga_reader_buy_chapter)) }
+                    TextButton(onClick = { onIntent(MangaReaderIntent.OpenSourceEdit) }) { Text(stringResource(R.string.edit_source)) }
+                    TextButton(onClick = {
+                        onIntent(MangaReaderIntent.DisableCurrentSource)
+                        onIntent(MangaReaderIntent.DismissSheet)
+                    }) { Text(stringResource(R.string.disable_source)) }
+                }
+            }
+            Spacer(Modifier.height(24.dp))
+        }
+    }
+}
+
+private val Boolean.intValue: Int get() = if (this) 1 else 0
+
+private fun updateBoolean(
+    onIntent: (MangaReaderIntent) -> Unit,
+    key: MangaReaderSettingKey,
+    value: Boolean,
+) = onIntent(MangaReaderIntent.UpdateSetting(key, value.intValue))
+
+private fun updateInt(
+    onIntent: (MangaReaderIntent) -> Unit,
+    key: MangaReaderSettingKey,
+    value: Int,
+) = onIntent(MangaReaderIntent.UpdateSetting(key, value))
+
+@Composable
+private fun SettingSwitch(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+    Row(
+        Modifier.fillMaxWidth(),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        Text(label, modifier = Modifier.weight(1f))
+        Switch(checked = checked, onCheckedChange = onCheckedChange)
+    }
+}
+
+@Composable
+private fun SettingSlider(label: String, value: Int, range: IntRange, onValueChange: (Int) -> Unit) {
+    var pending by remember(value) { mutableFloatStateOf(value.toFloat()) }
+    Column {
+        Text("$label: ${pending.toInt()}")
+        Slider(
+            value = pending.coerceIn(range.first.toFloat(), range.last.toFloat()),
+            onValueChange = { pending = it },
+            onValueChangeFinished = { onValueChange(pending.toInt()) },
+            valueRange = range.first.toFloat()..range.last.toFloat(),
+        )
+    }
+}

+ 671 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -0,0 +1,671 @@
+package io.legado.app.ui.book.manga
+
+import android.app.Application
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.data.repository.MangaReaderSessionRepository
+import io.legado.app.data.repository.MangaReaderLaunchRequest
+import io.legado.app.data.repository.MangaPaymentResult
+import io.legado.app.data.repository.MangaReaderSessionEvent
+import io.legado.app.model.manga.MangaPage
+import io.legado.app.model.manga.ReaderLoading
+import io.legado.app.data.model.MangaFooterConfig
+import io.legado.app.domain.gateway.MangaSettingsGateway
+import io.legado.app.domain.gateway.OtherSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.model.settings.MangaSettings
+import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonObject
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+class MangaReaderViewModel(
+    private val application: Application,
+    private val mangaSettingsGateway: MangaSettingsGateway,
+    private val otherSettingsGateway: OtherSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
+    private val sessionRepository: MangaReaderSessionRepository,
+) : ViewModel() {
+
+    private var refreshContentJob: Job? = null
+    private var latestMangaSettings = mangaSettingsGateway.currentSettings
+    private val bookConfigWriteMutex = Mutex()
+
+    private val _uiState = MutableStateFlow(MangaReaderUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<MangaReaderEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        sessionRepository.openSession()
+        viewModelScope.launch {
+            sessionRepository.events.collect { event ->
+                when (event) {
+                    MangaReaderSessionEvent.ContentUpdated -> refreshContent()
+                    is MangaReaderSessionEvent.LoadFailed -> showError(event.message)
+                    is MangaReaderSessionEvent.ConfirmProgress -> _uiState.update {
+                        it.copy(activeDialog = MangaReaderDialog.ConfirmProgress(event.progress))
+                    }
+                    MangaReaderSessionEvent.Loading,
+                    MangaReaderSessionEvent.LoadStarted -> showLoading()
+                    is MangaReaderSessionEvent.Message -> {
+                        _effects.tryEmit(MangaReaderEffect.ShowMessage(event.message))
+                    }
+                }
+            }
+        }
+        viewModelScope.launch {
+            mangaSettingsGateway.settings.collect { settings ->
+                latestMangaSettings = settings
+                _uiState.update { state ->
+                    state.copy(settings = readSettings(settings))
+                }
+            }
+        }
+        viewModelScope.launch {
+            otherSettingsGateway.settings.collect { settings ->
+                _uiState.update { it.copy(confirmAddToShelf = settings.showAddToShelfAlert) }
+            }
+        }
+    }
+
+    fun onIntent(intent: MangaReaderIntent) {
+        when (intent) {
+            is MangaReaderIntent.Initialize -> initialize(intent)
+            MangaReaderIntent.ResumeSession -> sessionRepository.resumeSession()
+            MangaReaderIntent.PauseSession -> sessionRepository.pauseSession()
+            MangaReaderIntent.NetworkAvailable -> sessionRepository.syncProgressOnNetworkAvailable()
+            MangaReaderIntent.RefreshBookSource -> launchAction {
+                sessionRepository.refreshBookSource()
+                refreshContent()
+            }
+            MangaReaderIntent.ReloadContent -> sessionRepository.reloadOrUpdateContent()
+            is MangaReaderIntent.ApplyReadingProgress -> sessionRepository.applyProgress(intent.progress)
+            is MangaReaderIntent.OpenChapter -> sessionRepository.openChapter(
+                intent.chapterIndex,
+                intent.pageIndex,
+            )
+            is MangaReaderIntent.ChangeSourceBook -> launchAction {
+                showLoading()
+                sessionRepository.changeSource(intent.book, intent.toc)
+            }
+            is MangaReaderIntent.AddExternalBookToShelf -> launchAction(
+                successMessage = application.getString(R.string.manga_reader_added_to_shelf),
+            ) { sessionRepository.addToShelf(intent.book, intent.toc) }
+            MangaReaderIntent.AddCurrentBookToShelf -> launchAction {
+                _uiState.update { it.copy(activeDialog = null) }
+                sessionRepository.addCurrentBookToShelf()
+                _effects.tryEmit(MangaReaderEffect.Finish(bookshelfChanged = true))
+            }
+            MangaReaderIntent.DiscardCurrentBookAndExit -> launchAction {
+                _uiState.update { it.copy(activeDialog = null) }
+                sessionRepository.removeCurrentTemporaryBook()
+                _effects.tryEmit(MangaReaderEffect.Finish())
+            }
+            MangaReaderIntent.DismissDialog -> _uiState.update { it.copy(activeDialog = null) }
+            MangaReaderIntent.DisableCurrentSource -> launchAction {
+                sessionRepository.disableSource()
+            }
+            MangaReaderIntent.RequestPayCurrentChapter -> _uiState.update {
+                it.copy(
+                    activeSheet = null,
+                    activeDialog = MangaReaderDialog.ConfirmPay(it.chapterName),
+                )
+            }
+            MangaReaderIntent.PayCurrentChapter -> {
+                _uiState.update { it.copy(activeDialog = null) }
+                payCurrentChapter()
+            }
+            MangaReaderIntent.OpenSourceLogin -> {
+                _uiState.value.sourceUrl?.let {
+                    _effects.tryEmit(MangaReaderEffect.OpenSourceLogin(it))
+                }
+            }
+            MangaReaderIntent.OpenSourceEdit -> {
+                _uiState.value.sourceUrl?.let {
+                    _effects.tryEmit(MangaReaderEffect.OpenSourceEdit(it))
+                }
+            }
+            MangaReaderIntent.BackPressed -> {
+                when {
+                    _uiState.value.activeDialog != null -> {
+                        _uiState.update { it.copy(activeDialog = null) }
+                    }
+                    _uiState.value.activeSheet != null -> {
+                        _uiState.update { it.copy(activeSheet = null) }
+                    }
+                    _uiState.value.menuVisible -> setMenuVisible(false)
+                    sessionRepository.hasBook() && !sessionRepository.inBookshelf() &&
+                            _uiState.value.confirmAddToShelf -> {
+                        _uiState.update { it.copy(activeDialog = MangaReaderDialog.AddToShelf) }
+                    }
+                    sessionRepository.hasBook() && !sessionRepository.inBookshelf() -> onIntent(
+                        MangaReaderIntent.DiscardCurrentBookAndExit
+                    )
+                    else -> _effects.tryEmit(MangaReaderEffect.Finish())
+                }
+            }
+            MangaReaderIntent.ToggleMenu -> setMenuVisible(!_uiState.value.menuVisible)
+            MangaReaderIntent.HideMenu -> setMenuVisible(false)
+            MangaReaderIntent.Retry -> launchAction {
+                showLoading()
+                sessionRepository.refreshCurrentChapter()
+            }
+            MangaReaderIntent.PreviousChapter -> sessionRepository.moveToPreviousChapter(true)
+            MangaReaderIntent.NextChapter -> sessionRepository.moveToNextChapter(true)
+            MangaReaderIntent.OpenCatalog -> emitAndHide(MangaReaderEffect.OpenCatalog)
+            MangaReaderIntent.OpenBookInfo -> emitAndHide(MangaReaderEffect.OpenBookInfo)
+            MangaReaderIntent.OpenChapterUrl -> emitAndHide(
+                MangaReaderEffect.OpenChapterUrl(readSettingsGateway.currentSettings.readUrlInBrowser)
+            )
+            MangaReaderIntent.ChangeSource -> {
+                setMenuVisible(false)
+                _uiState.update {
+                    it.copy(
+                        activeSheet = MangaReaderSheet.ChangeSource,
+                        changeSourceBook = sessionRepository.currentBook(),
+                    )
+                }
+            }
+            MangaReaderIntent.RefreshChapter -> {
+                setMenuVisible(false)
+                launchAction {
+                    showLoading()
+                    sessionRepository.refreshCurrentChapter()
+                }
+            }
+            MangaReaderIntent.OpenReaderSettings,
+            MangaReaderIntent.OpenPreDownloadSettings -> showSheet(MangaReaderSheet.Reader)
+            MangaReaderIntent.OpenAutoReadSettings -> showSheet(MangaReaderSheet.AutoRead)
+            MangaReaderIntent.OpenColorFilter -> showSheet(MangaReaderSheet.ColorFilter)
+            MangaReaderIntent.OpenClickSettings -> showSheet(MangaReaderSheet.ClickActions)
+            MangaReaderIntent.OpenSourceActions -> showSheet(MangaReaderSheet.SourceActions)
+            MangaReaderIntent.ToggleAutoRead -> _uiState.update {
+                it.copy(autoReadEnabled = !it.autoReadEnabled, menuVisible = false)
+            }
+            MangaReaderIntent.DismissSheet -> _uiState.update {
+                it.copy(activeSheet = null, changeSourceBook = null)
+            }
+            is MangaReaderIntent.UpdateSetting -> updateSetting(intent.key, intent.value)
+            is MangaReaderIntent.UpdateClickAction -> updateClickAction(intent.index, intent.action)
+            is MangaReaderIntent.PageStep -> requestPageStep(intent.direction)
+            is MangaReaderIntent.SeekToPage -> seekToPage(intent.pageIndex)
+            is MangaReaderIntent.VisibleItemChanged -> updateVisibleItem(intent.itemIndex)
+            is MangaReaderIntent.LongPressPage -> {
+                if (_uiState.value.settings.longPressEnabled) {
+                    saveImage(intent.imageUrl)
+                }
+            }
+        }
+    }
+
+    private fun initialize(intent: MangaReaderIntent.Initialize) {
+        showLoading()
+        viewModelScope.launch {
+            runCatching {
+                sessionRepository.initialize(
+                    MangaReaderLaunchRequest(
+                        bookUrl = intent.bookUrl,
+                        inBookshelf = intent.inBookshelf,
+                        chapterChanged = intent.chapterChanged,
+                    )
+                )
+            }.onSuccess { progress ->
+                _uiState.update { it.copy(inBookshelf = sessionRepository.inBookshelf()) }
+                progress?.let { syncedProgress ->
+                    _uiState.update {
+                        it.copy(activeDialog = MangaReaderDialog.ConfirmProgress(syncedProgress))
+                    }
+                }
+                refreshContent()
+            }.onFailure { error ->
+                showError(application.getString(R.string.manga_reader_init_failed, error.localizedMessage))
+            }
+        }
+    }
+
+    private fun payCurrentChapter() {
+        launchAction {
+            when (val result = sessionRepository.payCurrentChapter()) {
+                is MangaPaymentResult.OpenUrl -> _effects.tryEmit(
+                    MangaReaderEffect.OpenPaymentUrl(
+                        result.url,
+                        result.sourceOrigin,
+                        result.sourceName,
+                        result.sourceType,
+                    )
+                )
+                MangaPaymentResult.Refreshed -> Unit
+            }
+        }
+    }
+
+    private fun saveImage(url: String) {
+        launchAction(successMessage = application.getString(R.string.manga_reader_image_saved)) {
+            check(sessionRepository.saveImage(url)) {
+                application.getString(R.string.manga_reader_save_failed)
+            }
+        }
+    }
+
+    private fun launchAction(
+        successMessage: String? = null,
+        action: suspend () -> Unit,
+    ) {
+        viewModelScope.launch {
+            runCatching { action() }
+                .onSuccess { successMessage?.let { message ->
+                    _effects.tryEmit(MangaReaderEffect.ShowMessage(message))
+                } }
+                .onFailure { error ->
+                    _effects.tryEmit(
+                        MangaReaderEffect.ShowMessage(
+                            error.localizedMessage ?: application.getString(R.string.manga_reader_action_failed)
+                        )
+                    )
+                }
+        }
+    }
+
+    fun refreshContent() {
+        refreshContentJob?.cancel()
+        refreshContentJob = viewModelScope.launch {
+            val content = withContext(Dispatchers.IO) { sessionRepository.content() }
+            val items = content.items.mapIndexedNotNull { index, item ->
+                when (item) {
+                    is MangaPage -> MangaReaderItemUi.Page(
+                        key = "page:${item.chapterIndex}:${item.index}:${item.imageUrl}",
+                        imageUrl = item.imageUrl,
+                        chapterIndex = item.chapterIndex,
+                        chapterCount = item.chapterSize,
+                        pageIndex = item.index,
+                        pageCount = item.imageCount,
+                        chapterName = item.chapterName,
+                    )
+                    is ReaderLoading -> MangaReaderItemUi.ChapterEdge(
+                        key = "edge:${item.chapterIndex}:${item.index}:$index",
+                        message = item.message.orEmpty(),
+                    )
+                    else -> null
+                }
+            }.toImmutableList()
+            val safePosition = content.position.coerceIn(0, (items.size - 1).coerceAtLeast(0))
+            val bookName = sessionRepository.currentBookName()
+            val oldState = _uiState.value
+            val shouldPosition = oldState.pages.isEmpty() || oldState.isLoading ||
+                    oldState.bookName != bookName
+            _uiState.update { old ->
+                old.copy(
+                    bookName = bookName,
+                    bookAuthor = sessionRepository.currentBookAuthor(),
+                    bookUrl = sessionRepository.currentBookUrl(),
+                    chapterName = sessionRepository.currentChapterName(),
+                    chapterUrl = sessionRepository.currentChapterUrl(),
+                    sourceName = sessionRepository.currentSourceName(),
+                    sourceUrl = sessionRepository.currentSourceUrl(),
+                    sourceType = sessionRepository.currentSourceType(),
+                    pages = items,
+                    currentItemIndex = if (shouldPosition) safePosition else {
+                        old.currentItemIndex.coerceIn(0, (items.size - 1).coerceAtLeast(0))
+                    },
+                    chapterIndex = sessionRepository.currentChapterIndex(),
+                    chapterCount = sessionRepository.chapterCount(),
+                    isLoading = false,
+                    errorMessage = null,
+                    settings = readSettings(latestMangaSettings),
+                    scrollRequest = if (shouldPosition) {
+                        MangaScrollRequest(
+                            id = System.nanoTime(),
+                            itemIndex = safePosition,
+                            animated = false,
+                        )
+                    } else old.scrollRequest,
+                )
+            }
+            if (shouldPosition) updateVisibleItem(safePosition)
+        }
+    }
+
+    fun showLoading() {
+        _uiState.update { it.copy(isLoading = true, errorMessage = null) }
+    }
+
+    fun showError(message: String) {
+        _uiState.update { it.copy(isLoading = false, errorMessage = message) }
+    }
+
+    override fun onCleared() {
+        sessionRepository.closeSession()
+        super.onCleared()
+    }
+
+    private fun setMenuVisible(visible: Boolean) {
+        _uiState.update { it.copy(menuVisible = visible) }
+        _effects.tryEmit(MangaReaderEffect.SetSystemBarsVisible(visible))
+    }
+
+    private fun emitAndHide(effect: MangaReaderEffect) {
+        setMenuVisible(false)
+        _effects.tryEmit(effect)
+    }
+
+    private fun showSheet(sheet: MangaReaderSheet) {
+        setMenuVisible(false)
+        _uiState.update { it.copy(activeSheet = sheet) }
+    }
+
+    private fun updateClickAction(index: Int, action: Int) {
+        if (index !in 0..8) return
+        updateMangaPreference { settings ->
+            when (index) {
+                0 -> settings.copy(clickActionTL = action)
+                1 -> settings.copy(clickActionTC = action)
+                2 -> settings.copy(clickActionTR = action)
+                3 -> settings.copy(clickActionML = action)
+                4 -> settings.copy(clickActionMC = action)
+                5 -> settings.copy(clickActionMR = action)
+                6 -> settings.copy(clickActionBL = action)
+                7 -> settings.copy(clickActionBC = action)
+                8 -> settings.copy(clickActionBR = action)
+                else -> settings
+            }
+        }
+        _uiState.update { state ->
+            state.copy(settings = state.settings.copy(
+                clickActions = state.settings.clickActions.mapIndexed { i, old ->
+                    if (i == index) action else old
+                }.toImmutableList()
+            ))
+        }
+    }
+
+    private fun updateSetting(key: MangaReaderSettingKey, value: Int) {
+        val enabled = value != 0
+        when (key) {
+            MangaReaderSettingKey.SCROLL_MODE -> {
+                persistBookConfig { sessionRepository.updateReadConfig { mangaScrollMode = value } }
+                updateSettings { copy(scrollMode = value) }
+            }
+            MangaReaderSettingKey.SIDE_PADDING -> {
+                persistBookConfig { sessionRepository.updateReadConfig { webtoonSidePaddingDp = value } }
+                updateSettings { copy(sidePaddingPercent = value) }
+            }
+            MangaReaderSettingKey.BACKGROUND_RED,
+            MangaReaderSettingKey.BACKGROUND_GREEN,
+            MangaReaderSettingKey.BACKGROUND_BLUE -> {
+                val old = _uiState.value.settings.backgroundColor
+                val red = if (key == MangaReaderSettingKey.BACKGROUND_RED) value else (old.red * 255).toInt()
+                val green = if (key == MangaReaderSettingKey.BACKGROUND_GREEN) value else (old.green * 255).toInt()
+                val blue = if (key == MangaReaderSettingKey.BACKGROUND_BLUE) value else (old.blue * 255).toInt()
+                val color = Color(red, green, blue)
+                updateMangaPreference { it.copy(background = color.toArgb()) }
+                updateSettings { copy(backgroundColor = color) }
+            }
+            MangaReaderSettingKey.DISABLE_SCALE -> setAndUpdate(
+                { copy(disableMangaScale = enabled) }, { copy(disableScale = enabled) })
+            MangaReaderSettingKey.DISABLE_SCROLL_ANIMATION -> setAndUpdate(
+                { copy(disableMangaScrollAnimation = enabled) },
+                { copy(disableScrollAnimation = enabled) })
+            MangaReaderSettingKey.DISABLE_CROSS_FADE -> setAndUpdate(
+                { copy(disableMangaCrossFade = enabled) }, { copy(disableCrossFade = enabled) })
+            MangaReaderSettingKey.DISABLE_CLICK_SCROLL -> setAndUpdate(
+                { copy(disableClickScroll = enabled) }, { copy(disableClickScroll = enabled) })
+            MangaReaderSettingKey.LONG_PRESS -> setAndUpdate(
+                { copy(longClick = enabled) }, { copy(longPressEnabled = enabled) })
+            MangaReaderSettingKey.PRE_DOWNLOAD -> setAndUpdate(
+                { copy(preDownloadNum = value) }, { copy(preDownloadCount = value) })
+            MangaReaderSettingKey.AUTO_READ_SPEED -> setAndUpdate(
+                { copy(autoPageSpeed = value) }, { copy(autoReadSpeed = value) })
+            MangaReaderSettingKey.VOLUME_KEY_PAGE -> setAndUpdate(
+                { copy(volumeKeyPage = enabled) }, { copy(volumeKeyPage = enabled) })
+            MangaReaderSettingKey.REVERSE_VOLUME_KEY_PAGE -> setAndUpdate(
+                { copy(reverseVolumeKeyPage = enabled) },
+                { copy(reverseVolumeKeyPage = enabled) })
+            MangaReaderSettingKey.HIDE_MANGA_TITLE -> {
+                updateMangaPreference { it.copy(hideTitle = enabled) }
+                updateSettings { copy(hideMangaTitle = enabled) }
+                sessionRepository.reloadContent()
+            }
+            MangaReaderSettingKey.ENABLE_GRAY -> {
+                updateMangaPreference {
+                    it.copy(enableGray = enabled, enableEInk = if (enabled) false else it.enableEInk)
+                }
+                updateSettings { copy(enableGray = enabled, enableEInk = if (enabled) false else enableEInk) }
+            }
+            MangaReaderSettingKey.ENABLE_EINK -> {
+                updateMangaPreference {
+                    it.copy(enableEInk = enabled, enableGray = if (enabled) false else it.enableGray)
+                }
+                updateSettings { copy(enableEInk = enabled, enableGray = if (enabled) false else enableGray) }
+            }
+            MangaReaderSettingKey.EINK_THRESHOLD -> setAndUpdate(
+                { copy(eInkThreshold = value) }, { copy(eInkThreshold = value) })
+            MangaReaderSettingKey.FILTER_RED,
+            MangaReaderSettingKey.FILTER_GREEN,
+            MangaReaderSettingKey.FILTER_BLUE,
+            MangaReaderSettingKey.FILTER_ALPHA,
+            MangaReaderSettingKey.AUTO_BRIGHTNESS,
+            MangaReaderSettingKey.BRIGHTNESS -> updateColorSetting(key, value)
+            MangaReaderSettingKey.HIDE_FOOTER,
+            MangaReaderSettingKey.HIDE_CHAPTER_NAME,
+            MangaReaderSettingKey.HIDE_PAGE_NUMBER,
+            MangaReaderSettingKey.HIDE_PAGE_NUMBER_LABEL,
+            MangaReaderSettingKey.HIDE_CHAPTER,
+            MangaReaderSettingKey.HIDE_CHAPTER_LABEL,
+            MangaReaderSettingKey.HIDE_PROGRESS,
+            MangaReaderSettingKey.HIDE_PROGRESS_LABEL,
+            MangaReaderSettingKey.FOOTER_ALIGNMENT -> updateFooterSetting(key, value)
+        }
+    }
+
+    private fun updateSettings(update: MangaReaderSettings.() -> MangaReaderSettings) {
+        _uiState.update { it.copy(settings = it.settings.update()) }
+    }
+
+    private fun setAndUpdate(
+        write: MangaSettings.() -> MangaSettings,
+        update: MangaReaderSettings.() -> MangaReaderSettings,
+    ) {
+        updateMangaPreference(write)
+        updateSettings(update)
+    }
+
+    private fun updateMangaPreference(transform: (MangaSettings) -> MangaSettings) {
+        viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
+            mangaSettingsGateway.update(transform)
+        }
+    }
+
+    private fun persistBookConfig(block: suspend () -> Unit) {
+        viewModelScope.launch {
+            bookConfigWriteMutex.withLock { block() }
+        }
+    }
+
+    private fun updateColorSetting(key: MangaReaderSettingKey, value: Int) {
+        val current = _uiState.value.settings
+        val config = MangaColorFilterConfig(
+            r = current.filterRed,
+            g = current.filterGreen,
+            b = current.filterBlue,
+            a = current.filterAlpha,
+            l = current.brightness,
+            autoBrightness = current.autoBrightness,
+        )
+        when (key) {
+            MangaReaderSettingKey.FILTER_RED -> config.r = value
+            MangaReaderSettingKey.FILTER_GREEN -> config.g = value
+            MangaReaderSettingKey.FILTER_BLUE -> config.b = value
+            MangaReaderSettingKey.FILTER_ALPHA -> config.a = value
+            MangaReaderSettingKey.AUTO_BRIGHTNESS -> config.autoBrightness = value != 0
+            MangaReaderSettingKey.BRIGHTNESS -> config.l = value
+            else -> Unit
+        }
+        updateMangaPreference { it.copy(colorFilter = config.toJson()) }
+        updateSettings {
+            copy(
+                filterRed = config.r,
+                filterGreen = config.g,
+                filterBlue = config.b,
+                filterAlpha = config.a,
+                autoBrightness = config.autoBrightness,
+                brightness = config.l,
+            )
+        }
+        _effects.tryEmit(MangaReaderEffect.SetWindowBrightness(config.autoBrightness, config.l))
+    }
+
+    private fun updateFooterSetting(key: MangaReaderSettingKey, value: Int) {
+        val current = _uiState.value.settings
+        val config = MangaFooterConfig(
+            hideFooter = current.hideFooter,
+            hideChapterName = current.hideChapterName,
+            hidePageNumber = current.hidePageNumber,
+            hidePageNumberLabel = current.hidePageNumberLabel,
+            hideChapter = current.hideChapter,
+            hideChapterLabel = current.hideChapterLabel,
+            hideProgressRatio = current.hideProgress,
+            hideProgressRatioLabel = current.hideProgressLabel,
+            footerOrientation = current.footerAlignment,
+        )
+        val enabled = value != 0
+        when (key) {
+            MangaReaderSettingKey.HIDE_FOOTER -> config.hideFooter = enabled
+            MangaReaderSettingKey.HIDE_CHAPTER_NAME -> config.hideChapterName = enabled
+            MangaReaderSettingKey.HIDE_PAGE_NUMBER -> config.hidePageNumber = enabled
+            MangaReaderSettingKey.HIDE_PAGE_NUMBER_LABEL -> config.hidePageNumberLabel = enabled
+            MangaReaderSettingKey.HIDE_CHAPTER -> config.hideChapter = enabled
+            MangaReaderSettingKey.HIDE_CHAPTER_LABEL -> config.hideChapterLabel = enabled
+            MangaReaderSettingKey.HIDE_PROGRESS -> config.hideProgressRatio = enabled
+            MangaReaderSettingKey.HIDE_PROGRESS_LABEL -> config.hideProgressRatioLabel = enabled
+            MangaReaderSettingKey.FOOTER_ALIGNMENT -> config.footerOrientation = value
+            else -> Unit
+        }
+        updateMangaPreference { it.copy(footerConfig = GSON.toJson(config)) }
+        updateSettings {
+            copy(
+                hideFooter = config.hideFooter,
+                hideChapterName = config.hideChapterName,
+                hidePageNumber = config.hidePageNumber,
+                hidePageNumberLabel = config.hidePageNumberLabel,
+                hideChapter = config.hideChapter,
+                hideChapterLabel = config.hideChapterLabel,
+                hideProgress = config.hideProgressRatio,
+                hideProgressLabel = config.hideProgressRatioLabel,
+                footerAlignment = config.footerOrientation,
+            )
+        }
+    }
+
+    private fun requestPageStep(direction: Int) {
+        val state = _uiState.value
+        val target = mangaPageStepTarget(
+            currentIndex = state.currentItemIndex,
+            itemCount = state.pages.size,
+            direction = direction,
+        )
+        if (target == null) {
+            if (direction > 0) sessionRepository.moveToNextChapter(true)
+            else sessionRepository.moveToPreviousChapter(true)
+            return
+        }
+        _uiState.update {
+            it.copy(scrollRequest = MangaScrollRequest(System.nanoTime(), target, !it.settings.disableScrollAnimation))
+        }
+    }
+
+    private fun seekToPage(pageIndex: Int) {
+        val state = _uiState.value
+        val target = state.pages.indexOfFirst {
+            it is MangaReaderItemUi.Page &&
+                    it.chapterIndex == sessionRepository.currentChapterIndex() && it.pageIndex == pageIndex
+        }
+        if (target >= 0) {
+            _uiState.update { it.copy(scrollRequest = MangaScrollRequest(System.nanoTime(), target, false)) }
+        }
+    }
+
+    private fun updateVisibleItem(itemIndex: Int) {
+        val state = _uiState.value
+        val item = state.pages.getOrNull(itemIndex) as? MangaReaderItemUi.Page ?: return
+        sessionRepository.setVisiblePage(item.chapterIndex, item.pageIndex)
+        _uiState.update {
+            it.copy(
+                currentItemIndex = itemIndex,
+                currentPage = item.pageIndex,
+                pageCount = item.pageCount,
+                chapterIndex = item.chapterIndex,
+                chapterCount = item.chapterCount,
+                chapterName = item.chapterName,
+            )
+        }
+    }
+
+    private fun readSettings(settings: MangaSettings): MangaReaderSettings {
+        val colorFilter = GSON.fromJsonObject<MangaColorFilterConfig>(settings.colorFilter)
+            .getOrNull() ?: MangaColorFilterConfig()
+        val footer = GSON.fromJsonObject<MangaFooterConfig>(settings.footerConfig)
+            .getOrNull() ?: MangaFooterConfig()
+        return MangaReaderSettings(
+        scrollMode = sessionRepository.effectiveScrollMode(settings.scrollMode),
+        sidePaddingPercent = sessionRepository.effectiveSidePadding(settings.webtoonSidePaddingDp),
+        backgroundColor = Color(settings.background),
+        disableScale = settings.disableMangaScale,
+        disableScrollAnimation = settings.disableMangaScrollAnimation,
+        disableCrossFade = settings.disableMangaCrossFade,
+        disableClickScroll = settings.disableClickScroll,
+        longPressEnabled = settings.longClick,
+        preDownloadCount = settings.preDownloadNum,
+        autoReadSpeed = settings.autoPageSpeed,
+        volumeKeyPage = settings.volumeKeyPage,
+        reverseVolumeKeyPage = settings.reverseVolumeKeyPage,
+        hideMangaTitle = settings.hideTitle,
+        autoBrightness = colorFilter.autoBrightness,
+        brightness = colorFilter.l,
+        enableGray = settings.enableGray,
+        enableEInk = settings.enableEInk,
+        eInkThreshold = settings.eInkThreshold,
+        filterRed = colorFilter.r,
+        filterGreen = colorFilter.g,
+        filterBlue = colorFilter.b,
+        filterAlpha = colorFilter.a,
+        hideFooter = footer.hideFooter,
+        hideChapterName = footer.hideChapterName,
+        hidePageNumber = footer.hidePageNumber,
+        hidePageNumberLabel = footer.hidePageNumberLabel,
+        hideChapter = footer.hideChapter,
+        hideChapterLabel = footer.hideChapterLabel,
+        hideProgress = footer.hideProgressRatio,
+        hideProgressLabel = footer.hideProgressRatioLabel,
+        footerAlignment = footer.footerOrientation,
+        sourceOrigin = sessionRepository.currentSourceOrigin(),
+        clickActions = listOf(
+            settings.clickActionTL,
+            settings.clickActionTC,
+            settings.clickActionTR,
+            settings.clickActionML,
+            settings.clickActionMC,
+            settings.clickActionMR,
+            settings.clickActionBL,
+            settings.clickActionBC,
+            settings.clickActionBR,
+        ).toImmutableList(),
+    )
+    }
+}

+ 107 - 1070
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -1,503 +1,131 @@
 package io.legado.app.ui.book.manga
 
-import android.annotation.SuppressLint
 import android.content.Intent
-import android.graphics.Color
-import android.os.Build
 import android.os.Bundle
 import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
 import android.view.WindowManager
-import android.view.animation.LinearInterpolator
-import androidx.activity.addCallback
-import androidx.appcompat.app.AlertDialog
-import androidx.core.view.doOnLayout
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.PagerSnapHelper
-import com.bumptech.glide.Glide
-import com.bumptech.glide.integration.recyclerview.RecyclerViewPreloader
-import com.bumptech.glide.request.target.Target.SIZE_ORIGINAL
-import com.bumptech.glide.util.FixedPreloadSizeProvider
-import com.google.android.material.transition.platform.MaterialContainerTransform
-import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
-import com.jaredrummler.android.colorpicker.ColorPickerDialogListener
-import io.legado.app.BuildConfig
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.EventBus
-import io.legado.app.data.appDb
-import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookChapter
-import io.legado.app.data.entities.BookProgress
-import io.legado.app.data.entities.BookSource
-import io.legado.app.databinding.ActivityMangaBinding
-import io.legado.app.databinding.ViewLoadMoreBinding
-import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.exception.NoStackTraceException
-import io.legado.app.help.book.BookHelp
-import io.legado.app.help.book.isImage
-import io.legado.app.help.book.isLocal
-import io.legado.app.help.coroutine.Coroutine
-import io.legado.app.help.source.getSourceType
-import io.legado.app.help.storage.Backup
-import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
-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.base.BaseComposeActivity
 import io.legado.app.receiver.NetworkChangedListener
-import io.legado.app.ui.book.changesource.ChangeBookSourceDialog
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.READER_RESULT_DELETED
-import io.legado.app.ui.book.manga.config.MangaAutoReadDialog
-import io.legado.app.ui.book.manga.config.MangaClickActionConfigDialog
-import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
-import io.legado.app.ui.book.manga.config.MangaColorFilterDialog
-import io.legado.app.ui.book.manga.config.MangaFooterSettingDialog
-import io.legado.app.ui.book.manga.config.MangaFooterSettingDialog.Companion.MANGA_B
-import io.legado.app.ui.book.manga.config.MangaScrollMode
-import io.legado.app.ui.book.manga.entities.BaseMangaPage
-import io.legado.app.ui.book.manga.entities.MangaFooterConfig
-import io.legado.app.ui.book.manga.entities.MangaPage
-import io.legado.app.ui.book.manga.recyclerview.MangaAdapter
-import io.legado.app.ui.book.manga.recyclerview.MangaLayoutManager
-import io.legado.app.ui.book.manga.recyclerview.ScrollTimer
-import io.legado.app.ui.book.manga.recyclerview.WebtoonFrame
-import io.legado.app.ui.book.read.MangaMenu
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.browser.WebViewActivity
-import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.login.SourceLoginActivity
-import io.legado.app.ui.widget.number.NumberPickerDialog
-import io.legado.app.ui.widget.recycler.LoadMoreView
-import io.legado.app.utils.GSON
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StartActivityContract
-import io.legado.app.utils.buildMainHandler
-import io.legado.app.utils.canScroll
-import io.legado.app.utils.fastBinarySearch
-import io.legado.app.utils.findCenterViewPosition
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.gone
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.isTrue
-import io.legado.app.utils.observeEvent
-import io.legado.app.utils.showDialogFragment
+import io.legado.app.utils.openUrl
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.Dispatchers.IO
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import org.koin.androidx.viewmodel.ext.android.viewModel
-import java.text.DecimalFormat
-import kotlin.math.ceil
+import kotlinx.coroutines.flow.collectLatest
 
-class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewModel>(),
-    ReadManga.Callback, ChangeBookSourceDialog.CallBack, MangaMenu.CallBack,
-    MangaColorFilterDialog.Callback, ScrollTimer.ScrollCallback, MangaFooterSettingDialog.Callback,
-    MangaAutoReadDialog.Callback, ColorPickerDialogListener {
+/**
+ * Android compatibility host for the Compose manga reader.
+ *
+ * The reader surface and interaction state live in [MangaReaderScreen] and
+ * [MangaReaderViewModel]. This activity only owns Android activity results,
+ * window state and external activity navigation.
+ */
+class ReadMangaActivity : BaseComposeActivity(imageBg = false) {
 
-    private val mLayoutManager by lazy {
-        MangaLayoutManager(this)
-    }
-
-    private val mAdapter: MangaAdapter by lazy {
-        MangaAdapter(this) { viewModel.mangaSettings.value }
-    }
-
-    private val mSizeProvider by lazy {
-        FixedPreloadSizeProvider<Any>(resources.displayMetrics.widthPixels, SIZE_ORIGINAL)
-    }
-
-    private val mPagerSnapHelper: PagerSnapHelper by lazy {
-        PagerSnapHelper()
-    }
-
-    private lateinit var mMangaFooterConfig: MangaFooterConfig
-    private val mLabelBuilder by lazy { StringBuilder() }
-
-    private var mMenu: Menu? = null
+    private val readerViewModel by viewModel<MangaReaderViewModel>()
+    private val networkChangedListener by lazy { NetworkChangedListener(this) }
 
-    private var scrollMode: Int = MangaScrollMode.PAGE_LEFT_TO_RIGHT
-
-    private var mRecyclerViewPreloader: RecyclerViewPreloader<Any>? = null
-
-    private val networkChangedListener by lazy {
-        NetworkChangedListener(this)
-    }
-
-    private var justInitData: Boolean = false
-    private var isExitActionRunning = false
     private var isRestoredFromSavedState = false
-    private var syncDialog: AlertDialog? = null
-    private val handler by lazy { buildMainHandler() }
-    private val mScrollTimer by lazy {
-        ScrollTimer(this, binding.recyclerView, lifecycleScope).apply {
-            setSpeed(viewModel.mangaSettings.value.autoPageSpeed)
-        }
-    }
-    private var enableAutoScrollPage = false
-    private var enableAutoScroll = false
-    private var enableScroll = false
-    private val mLinearInterpolator by lazy {
-        LinearInterpolator()
-    }
+    private var justInitialized = false
 
     private val sourceEditActivity =
         registerForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) {
             if (it.resultCode == RESULT_OK) {
-                viewModel.upBookSource {
-                    handler.post {
-                        binding.mangaMenu.upBookView()
-                    }
-                }
+                readerViewModel.onIntent(MangaReaderIntent.RefreshBookSource)
             }
         }
 
-    private val loadMoreView by lazy {
-        LoadMoreView(this).apply {
-//            setBackgroundColor(getCompatColor(R.color.book_ant_10))
-//            setLoadingColor(R.color.white)
-//            setLoadingTextColor(R.color.white)
-        }
-    }
-
-    //打开目录返回选择章节返回结果
     private val tocActivity = registerForActivityResult(TocActivityResult()) {
-        it?.let {
-            viewModel.openChapter(it.first, it.second)
+        it?.let { (chapterIndex, pageIndex) ->
+            readerViewModel.onIntent(MangaReaderIntent.OpenChapter(chapterIndex, pageIndex))
         }
     }
+
     private val bookInfoActivity =
         registerForActivityResult(StartActivityContract(BookInfoActivity::class.java)) {
             if (it.resultCode == RESULT_OK) {
                 setResult(READER_RESULT_DELETED)
-                super.finish()
+                finish()
             } else {
-                ReadManga.loadOrUpContent()
+                readerViewModel.onIntent(MangaReaderIntent.ReloadContent)
             }
         }
-    override val binding by viewBinding(ActivityMangaBinding::inflate)
-    override val viewModel by viewModel<ReadMangaViewModel>()
-    private val loadingViewVisible get() = binding.flLoading.isVisible
-    private val df by lazy {
-        DecimalFormat("0.0%")
-    }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         isRestoredFromSavedState = savedInstanceState != null
-        setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
-        val transform = MaterialContainerTransform().apply {
-            addTarget(binding.rootView)
-            scrimColor = Color.TRANSPARENT
-            duration = 500
-        }
-        window.sharedElementEnterTransition = transform
-        window.sharedElementReturnTransition = transform
-        upLayoutInDisplayCutoutMode()
         super.onCreate(savedInstanceState)
-        binding.rootView.transitionName = intent.getStringExtra("transitionName")
-        ReadManga.register(this)
-        upSystemUiVisibility(false)
-        initRecyclerView()
-        binding.tvRetry.setOnClickListener {
-            binding.llLoading.isVisible = true
-            binding.llRetry.isGone = true
-            ReadManga.loadOrUpContent()
-        }
-        binding.flLoading.isVisible = !ReadConfig.isEInkMode
-        mAdapter.addFooterView {
-            ViewLoadMoreBinding.bind(loadMoreView)
-        }
-        loadMoreView.setOnClickListener {
-            if (!loadMoreView.isLoading && ReadManga.hasNextChapter) {
-                loadMoreView.startLoad()
-                ReadManga.loadOrUpContent()
-            }
-        }
-        loadMoreView.gone()
-        mMangaFooterConfig =
-            GSON.fromJsonObject<MangaFooterConfig>(viewModel.mangaSettings.value.footerConfig).getOrNull()
-                ?: MangaFooterConfig()
-
-        onBackPressedDispatcher.addCallback(this) {
-            requestReaderExit()
-        }
+        toggleSystemBar(false)
+        justInitialized = true
+        initializeReader(intent)
     }
 
-    private fun requestReaderExit() {
-        if (isExitActionRunning) return
-        val book = ReadManga.book
-        if (book == null || ReadManga.inBookshelf) {
-            finishReader()
-            return
-        }
-        if (!OtherConfig.showAddToShelfAlert) {
-            discardCurrentNotShelfBook()
-            return
-        }
-        alert(title = getString(R.string.add_to_bookshelf)) {
-            setCancelable(false)
-            setMessage(getString(R.string.check_add_bookshelf, book.name))
-            okButton {
-                addCurrentBookToBookshelf()
-            }
-            noButton {
-                discardCurrentNotShelfBook()
-            }
+    @Composable
+    override fun Content() {
+        val state by readerViewModel.uiState.collectAsStateWithLifecycle()
+        LaunchedEffect(Unit) {
+            readerViewModel.effects.collectLatest(::handleEffect)
         }
+        MangaReaderScreen(state = state, onIntent = readerViewModel::onIntent)
     }
 
-    private fun addCurrentBookToBookshelf() {
-        isExitActionRunning = true
-        viewModel.addCurrentBookToBookshelf(
-            success = {
-                setResult(RESULT_OK)
+    private fun handleEffect(effect: MangaReaderEffect) {
+        when (effect) {
+            is MangaReaderEffect.Finish -> {
+                if (effect.bookshelfChanged) setResult(RESULT_OK)
                 finishReader()
-            },
-            onFailure = { isExitActionRunning = false }
-        )
-    }
-
-    private fun discardCurrentNotShelfBook() {
-        isExitActionRunning = true
-        viewModel.removeCurrentNotShelfBook(
-            success = ::finishReader,
-            onFailure = { isExitActionRunning = false }
-        )
-    }
-
-    private fun finishReader() {
-        if (ReadManga.inBookshelf && !isRestoredFromSavedState) {
-            supportFinishAfterTransition()
-        } else {
-            finish()
-        }
-    }
-
-    override fun observeLiveBus() {
-        observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
-            mMangaFooterConfig = it
-            val item = mAdapter.getItem(binding.recyclerView.findCenterViewPosition())
-            upInfoBar(item)
-        }
-    }
-
-    private fun initRecyclerView() {
-        val mangaColorFilter =
-            GSON.fromJsonObject<MangaColorFilterConfig>(viewModel.mangaSettings.value.colorFilter).getOrNull()
-                ?: MangaColorFilterConfig()
-        mAdapter.run {
-            setMangaImageColorFilter(mangaColorFilter)
-            enableMangaEInk(viewModel.mangaSettings.value.enableEInk, viewModel.mangaSettings.value.eInkThreshold)
-            enableGray(viewModel.mangaSettings.value.enableGray)
-        }
-
-        viewModel.initData(intent) {
-            setScrollMode(viewModel.getEffectiveScrollMode())
-            updateWebtoonSidePadding(viewModel.getEffectiveWebtoonSidePadding())
-        }
-
-        binding.recyclerView.run {
-            adapter = mAdapter
-            itemAnimator = null
-            layoutManager = mLayoutManager
-            setHasFixedSize(true)
-            setDisableMangaScale(viewModel.mangaSettings.value.disableMangaScale)
-            setRecyclerViewPreloader(viewModel.mangaSettings.value.preDownloadNum)
-            setPreScrollListener { _, _, _, position ->
-                if (mAdapter.isNotEmpty()) {
-                    val item = mAdapter.getItem(position)
-                    if (item is BaseMangaPage) {
-                        if (ReadManga.durChapterIndex < item.chapterIndex) {
-                            ReadManga.moveToNextChapter()
-                        } else if (ReadManga.durChapterIndex > item.chapterIndex) {
-                            ReadManga.moveToPrevChapter()
-                        } else {
-                            ReadManga.durChapterPos = item.index
-                            ReadManga.curPageChanged()
-                        }
-                        if (item is MangaPage) {
-                            binding.mangaMenu.upSeekBar(item.index, item.imageCount)
-                            upInfoBar(item)
-                        }
-                    }
-                }
             }
-        }
-
-        binding.webtoonFrame.settingsProvider = { viewModel.mangaSettings.value }
-        binding.webtoonFrame.actionHandler = object : WebtoonFrame.ClickActionHandler {
-            override fun showMenu() {
-                if (!binding.mangaMenu.isVisible && !loadingViewVisible) {
-                    binding.mangaMenu.runMenuIn()
-                }
+            MangaReaderEffect.OpenCatalog -> openCatalog()
+            MangaReaderEffect.OpenBookInfo -> openBookInfoActivity()
+            is MangaReaderEffect.OpenChapterUrl -> openCurrentChapterUrl(effect.externalBrowser)
+            is MangaReaderEffect.ShowMessage -> toastOnUi(effect.message)
+            is MangaReaderEffect.OpenSourceLogin -> startActivity<SourceLoginActivity> {
+                putExtra("type", "bookSource")
+                putExtra("key", effect.sourceUrl)
             }
-
-            override fun nextPage() {
-                scrollToNext()
+            is MangaReaderEffect.OpenSourceEdit -> sourceEditActivity.launch {
+                putExtra("sourceUrl", effect.sourceUrl)
             }
-
-            override fun prevPage() {
-                scrollToPrev()
-            }
-
-            override fun nextChapter() {
-                ReadManga.moveToNextChapter(toFirst = true)
+            is MangaReaderEffect.OpenPaymentUrl -> startActivity<WebViewActivity> {
+                putExtra("title", getString(R.string.chapter_pay))
+                putExtra("url", effect.url)
+                putExtra("sourceOrigin", effect.sourceOrigin)
+                putExtra("sourceName", effect.sourceName)
+                putExtra("sourceType", effect.sourceType)
             }
-
-            override fun prevChapter() {
-                ReadManga.moveToPrevChapter(toFirst = true)
-            }
-        }
-
-        binding.webtoonFrame.longPressListener = {
-            val centerPos = binding.recyclerView.findCenterViewPosition()
-            val currentItem = mAdapter.getItem(centerPos)
-            val page = currentItem as? MangaPage
-            page?.let { item ->
-                selector(
-                    arrayListOf(
-                        SelectItem("保存", "save"),
-                        SelectItem("分享", "share")
-                    )
-                ) { _, option, _ ->
-                    when (option.value) {
-                        "save" -> viewModel.saveImageToGallery(item.mImageUrl)
-                        // "share" -> shareImage(item.mImageUrl)
-                    }
-                }
+            is MangaReaderEffect.SetWindowBrightness -> {
+                if (effect.auto) resetWindowToSystemBrightness()
+                else updateWindowBrightness(effect.brightness)
             }
+            is MangaReaderEffect.SetSystemBarsVisible -> toggleSystemBar(effect.visible)
         }
-        setBackground()
     }
 
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
-        viewModel.initData(intent)
+        setIntent(intent)
+        initializeReader(intent)
     }
 
-    override fun onPostCreate(savedInstanceState: Bundle?) {
-        super.onPostCreate(savedInstanceState)
-        viewModel.initData(intent)
-        justInitData = true
-    }
-
-    override fun upContent() {
-        lifecycleScope.launch {
-            binding.mangaMenu.upBookView()
-            val data = withContext(IO) { ReadManga.mangaContents }
-            val pos = data.pos
-            val list = data.items
-            val curFinish = data.curFinish
-            val nextFinish = data.nextFinish
-            mAdapter.submitList(list) {
-                if (loadingViewVisible && curFinish) {
-                    binding.infobar.isVisible = true
-                    upInfoBar(list[pos])
-                    mLayoutManager.scrollToPositionWithOffset(pos, 0)
-                    binding.flLoading.isGone = true
-                    loadMoreView.visible()
-                    val chapter = ReadManga.curMangaChapter
-                    if (chapter != null && chapter.imageCount > 0) {
-                        binding.mangaMenu.upSeekBar(
-                            ReadManga.durChapterPos, chapter.imageCount
-                        )
-                    } else {
-                        binding.mangaMenu.upSeekBar(1, 2)
-                    }
-                }
-
-                if (curFinish) {
-                    if (!ReadManga.hasNextChapter) {
-                        loadMoreView.noMore("暂无章节了!")
-                    } else if (nextFinish) {
-                        loadMoreView.stopLoad()
-                    } else {
-                        loadMoreView.startLoad()
-                    }
-                }
-            }
-        }
-    }
-
-    private fun upInfoBar(page: Any?) {
-        if (page !is MangaPage) {
-            binding.webtoonFrame.updateAccessibilityState(getString(R.string.loading))
-            return
-        }
-        val chapterIndex = page.chapterIndex
-        val chapterSize = page.chapterSize
-        val chapterPos = page.index
-        val imageCount = page.imageCount
-        val chapterName = page.mChapterName
-        binding.webtoonFrame.updateAccessibilityState(
-            listOfNotNull(
-                chapterName.takeIf(String::isNotBlank),
-                getString(R.string.a11y_read_progress_page, chapterPos + 1, imageCount),
-                getString(R.string.a11y_read_progress_chapter, chapterIndex + 1, chapterSize),
-            ).joinToString(", ")
-        )
-        mMangaFooterConfig.run {
-            mLabelBuilder.clear()
-            binding.infobar.isGone = hideFooter
-            binding.infobar.textInfoAlignment = footerOrientation
-
-            if (!hideChapterName) {
-                mLabelBuilder.append(chapterName).append(" ")
-            }
-
-            if (!hidePageNumber) {
-                if (!hidePageNumberLabel) {
-                    mLabelBuilder.append(getString(R.string.manga_check_page_number))
-                }
-                mLabelBuilder.append("${chapterPos + 1}/${imageCount}").append(" ")
-            }
-
-            if (!hideChapter) {
-                if (!hideChapterLabel) {
-                    mLabelBuilder.append(getString(R.string.manga_check_chapter))
-                }
-                mLabelBuilder.append("${chapterIndex + 1}/${chapterSize}").append(" ")
-            }
-
-            if (!hideProgressRatio) {
-                if (!hideProgressRatioLabel) {
-                    mLabelBuilder.append(getString(R.string.manga_check_progress))
-                }
-                val percent = if (chapterSize == 0 || imageCount == 0 && chapterIndex == 0) {
-                    "0.0%"
-                } else if (imageCount == 0) {
-                    df.format((chapterIndex + 1.0f) / chapterSize.toDouble())
-                } else {
-                    var percent =
-                        df.format(
-                            chapterIndex * 1.0f / chapterSize + 1.0f /
-                                    chapterSize * (chapterPos + 1) / imageCount.toDouble()
-                        )
-                    if (percent == "100.0%" && (chapterIndex + 1 != chapterSize || chapterPos + 1 != imageCount)) {
-                        percent = "99.9%"
-                    }
-                    percent
-                }
-                mLabelBuilder.append(percent)
-            }
-        }
-        binding.infobar.update(
-            if (mLabelBuilder.isEmpty()) "" else mLabelBuilder.toString()
+    private fun initializeReader(androidIntent: Intent) {
+        readerViewModel.onIntent(
+            MangaReaderIntent.Initialize(
+                bookUrl = androidIntent.getStringExtra("bookUrl"),
+                inBookshelf = androidIntent.getBooleanExtra("inBookshelf", true),
+                chapterChanged = androidIntent.getBooleanExtra("chapterChanged", false),
+            )
         )
     }
 
@@ -505,669 +133,78 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
         super.onResume()
         networkChangedListener.register()
         networkChangedListener.onNetworkChanged = {
-            // 当网络是可用状态且无需初始化时同步进度(初始化中已有同步进度逻辑)
-            if (ReadConfig.syncBookProgressPlus && NetworkUtils.isAvailable() && !justInitData) {
-                ReadManga.syncProgress({ progress -> sureNewProgress(progress) })
+            if (NetworkUtils.isAvailable() && !justInitialized) {
+                readerViewModel.onIntent(MangaReaderIntent.NetworkAvailable)
             }
         }
-        if (enableAutoScrollPage) {
-            mScrollTimer.isEnabledPage = true
-        }
-        if (enableAutoScroll) {
-            mScrollTimer.isEnabled = true
-        }
-        ReadManga.initReadTime()
-        ReadManga.startAutoSaveSession()
+        justInitialized = false
+        readerViewModel.onIntent(MangaReaderIntent.ResumeSession)
     }
 
     override fun onPause() {
-        super.onPause()
-        if (ReadManga.inBookshelf) {
-            ReadManga.saveRead()
-            if (!BuildConfig.DEBUG) {
-                if (ReadConfig.syncBookProgressPlus) {
-                    ReadManga.syncProgress()
-                } else {
-                    ReadManga.uploadProgress()
-                }
-                Backup.autoBack(this)
-            }
-        }
-        ReadManga.stopAutoSaveSession()
-        ReadManga.commitReadSession()
-        ReadManga.cancelPreDownloadTask()
+        readerViewModel.onIntent(MangaReaderIntent.PauseSession)
         networkChangedListener.unRegister()
-        mScrollTimer.isEnabledPage = false
-        mScrollTimer.isEnabled = false
-    }
-
-    override fun onColorSelected(dialogId: Int, color: Int){
-        if (dialogId == MANGA_B)
-        {
-            updateMangaSetting(afterUpdate = ::setBackground) { it.copy(background = color) }
-        }
-    }
-
-    override fun onDialogDismissed(dialogId: Int) = Unit
-
-    private fun updateMangaSetting(
-        afterUpdate: () -> Unit = {},
-        transform: (MangaSettings) -> MangaSettings,
-    ) {
-        lifecycleScope.launch {
-            viewModel.updateMangaSettings(transform)
-            afterUpdate()
-        }
-    }
-
-    override fun loadFail(msg: String) {
-        lifecycleScope.launch {
-            if (loadingViewVisible) {
-                binding.llLoading.isGone = true
-                binding.llRetry.isVisible = true
-                binding.tvMsg2.setMessage(msg)
-            } else {
-                loadMoreView.error(null, "加载失败,点击重试")
-            }
-        }
-    }
-
-    override fun onDestroy() {
-        ReadManga.unregister(this)
-        super.onDestroy()
-    }
-
-    override fun onLowMemory() {
-        super.onLowMemory()
-        Glide.get(this).clearMemory()
-    }
-
-    override fun sureNewProgress(progress: BookProgress) {
-        syncDialog?.dismiss()
-        syncDialog = alert(R.string.get_book_progress) {
-            setMessage(R.string.cloud_progress_exceeds_current)
-            okButton {
-                ReadManga.setProgress(progress)
-            }
-            noButton()
-        }
-    }
-
-    override fun showLoading() {
-        lifecycleScope.launch {
-            binding.flLoading.isVisible = true
-        }
-    }
-
-    override fun startLoad() {
-        lifecycleScope.launch {
-            loadMoreView.startLoad()
-        }
-    }
-
-    override fun scrollBy(distance: Int) {
-        if (!binding.recyclerView.canScroll(1)) {
-            return
-        }
-        val time = ceil(16f / distance * 10000).toInt()
-        binding.recyclerView.smoothScrollBy(10000, 10000, mLinearInterpolator, time)
-    }
-
-    override fun scrollPage(direction: Int) {
-        if (scrollMode == MangaScrollMode.WEBTOON || scrollMode == MangaScrollMode.WEBTOON_WITH_GAP) {
-            // 条漫平滑滚动距离
-            val dy =
-                (binding.recyclerView.height - binding.recyclerView.paddingTop - binding.recyclerView.paddingBottom) * direction
-            binding.recyclerView.smoothScrollBy(0, dy)
-        } else {
-            // 分页按 position 翻页
-            val layoutManager = binding.recyclerView.layoutManager as? LinearLayoutManager ?: return
-            val currentPosition = layoutManager.findFirstVisibleItemPosition()
-            val nextPosition = currentPosition + direction
-            val itemCount = binding.recyclerView.adapter?.itemCount ?: return
-
-            if (nextPosition in 0 until itemCount) {
-                binding.recyclerView.smoothScrollToPosition(nextPosition)
-            }
-        }
-    }
-
-
-    override val oldBook: Book?
-        get() = ReadManga.book
-
-    override fun changeTo(source: BookSource, book: Book, toc: List<BookChapter>) {
-        if (book.isImage) {
-            binding.flLoading.isVisible = true
-            viewModel.changeTo(book, toc)
-        } else {
-            toastOnUi("所选择的源不是漫画源")
-        }
-    }
-
-    override fun updateColorFilter(config: MangaColorFilterConfig) {
-        mAdapter.setMangaImageColorFilter(config)
-        if (config.autoBrightness) {
-            resetWindowToSystemBrightness()
-        } else {
-            updateWindowBrightness(config.l)
-        }
-    }
-
-    @SuppressLint("StringFormatMatches")
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.book_manga, menu)
-        upMenu(menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-
-    override fun showLogin() {
-        ReadManga.bookSource?.let {
-            startActivity<SourceLoginActivity> {
-                putExtra("type", "bookSource")
-                putExtra("key", it.bookSourceUrl)
-            }
-        }
-    }
-
-    override fun payAction() {
-        val book = ReadManga.book ?: return
-        if (book.isLocal) return
-        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadManga.durChapterIndex)
-        if (chapter == null) {
-            toastOnUi("no chapter")
-            return
-        }
-        alert(R.string.chapter_pay) {
-            setMessage(chapter.title)
-            yesButton {
-                Coroutine.async(lifecycleScope) {
-                    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)
-                    analyzeRule.setCoroutineContext(coroutineContext)
-                    analyzeRule.setBaseUrl(chapter.url)
-                    analyzeRule.setChapter(chapter)
-                    analyzeRule.evalJS(payAction).toString()
-                }.onSuccess(IO) {
-                    if (it.isAbsUrl()) {
-                        startActivity<WebViewActivity> {
-                            val bookSource = ReadManga.bookSource
-                            putExtra("title", getString(R.string.chapter_pay))
-                            putExtra("url", it)
-                            putExtra("sourceOrigin", bookSource?.bookSourceUrl)
-                            putExtra("sourceName", bookSource?.bookSourceName)
-                            putExtra("sourceType", bookSource?.getSourceType())
-                        }
-                    } else if (it.isTrue()) {
-                        //购买成功后刷新目录
-                        ReadManga.book?.let {
-                            ReadManga.curMangaChapter = null
-                            BookHelp.delContent(book, chapter)
-                            viewModel.loadChapterList(book)
-                        }
-                    }
-                }.onError {
-                    AppLog.put("执行购买操作出错\n${it.localizedMessage}", it, true)
-                }
-            }
-            noButton()
-        }
-    }
-
-    override fun disableSource() {
-        viewModel.disableSource()
-    }
-
-    override fun openSourceEditActivity() {
-        ReadManga.bookSource?.let {
-            sourceEditActivity.launch {
-                putExtra("sourceUrl", it.bookSourceUrl)
-            }
-        }
-    }
-
-    /**
-     * 菜单
-     */
-    @SuppressLint("StringFormatMatches", "NotifyDataSetChanged")
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_change_source -> {
-                binding.mangaMenu.runMenuOut()
-                ReadManga.book?.let {
-                    showDialogFragment(ChangeBookSourceDialog(it.name, it.author))
-                }
-            }
-
-            R.id.menu_refresh -> {
-                binding.flLoading.isVisible = true
-                ReadManga.book?.let {
-                    viewModel.refreshContentDur(it)
-                }
-            }
-
-            R.id.menu_pre_manga_number -> {
-                showNumberPickerDialog(
-                    0,
-                    getString(R.string.pre_download),
-                    viewModel.mangaSettings.value.preDownloadNum
-                ) {
-                    updateMangaSetting { settings -> settings.copy(preDownloadNum = it) }
-                    item.title = getString(R.string.pre_download_m, it)
-                    setRecyclerViewPreloader(it)
-                }
-            }
-
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    override fun openCatalog() {
-        ReadManga.book?.let {
-            tocActivity.launch(it.bookUrl)
-        }
-    }
-
-    override fun showFooterConfig() {
-        showDialogFragment(MangaFooterSettingDialog().apply {
-            initialScrollMode = viewModel.getEffectiveScrollMode()
-            initialWebtoonSidePadding = viewModel.getEffectiveWebtoonSidePadding()
-            callback = this@ReadMangaActivity
-        })
-    }
-
-    override fun showColorFilterConfig() {
-        binding.mangaMenu.runMenuOut()
-        showDialogFragment(MangaColorFilterDialog())
-    }
-
-    override fun showClickConfig() {
-        binding.mangaMenu.runMenuOut()
-        showDialogFragment(MangaClickActionConfigDialog())
-    }
-
-    override fun showScrollModeDialog() {
-        showDialogFragment(MangaAutoReadDialog().apply {
-            initialAutoPageEnabled = enableScroll
-            callback = this@ReadMangaActivity
-        })
-    }
-
-    //漫画模式
-    override fun onScrollModeChanged(mode: Int) {
-        onAutoPageToggle(false)
-        viewModel.updateReadConfig {
-            mangaScrollMode = mode
-        }
-        setScrollMode(mode)
-        updateWebtoonSidePadding(viewModel.mangaSettings.value.webtoonSidePaddingDp)
-        setAutoReadEnabled(false)
-    }
-
-    //点击滑动
-    override fun onClickScrollDisabledChanged(disabled: Boolean) {
-        updateMangaSetting { it.copy(disableClickScroll = disabled) }
-    }
-
-    override fun onScrollAniDisabledChanged(disabled: Boolean) {
-        updateMangaSetting { it.copy(disableMangaScrollAnimation = disabled) }
-    }
-
-    override fun onCrossFadeDisabledChanged(disabled: Boolean) {
-        updateMangaSetting { it.copy(disableMangaCrossFade = disabled) }
-    }
-
-    //双击缩放
-    override fun onMangaScaleDisabledChanged(disabled: Boolean) {
-        updateMangaSetting { it.copy(disableMangaScale = disabled) }
-        setDisableMangaScale(disabled)
-    }
-
-    //侧边留白
-    override fun upSidePadding(padding: Int) {
-        viewModel.updateReadConfig {
-            webtoonSidePaddingDp = padding
-        }
-        updateWebtoonSidePadding(padding)
-    }
-
-    //墨水屏
-    override fun updateEpaperMode(enabled: Boolean, threshold: Int) {
-        updateMangaSetting {
-            it.copy(enableEInk = enabled, enableGray = false, eInkThreshold = threshold)
-        }
-        mAdapter.enableMangaEInk(enabled, threshold)
-    }
-
-    //灰度
-    override fun updateGrayMode(enabled: Boolean) {
-        updateMangaSetting { it.copy(enableEInk = false, enableGray = enabled) }
-        mAdapter.enableGray(enabled)
-    }
-
-    //自动翻页
-    override fun onAutoPageToggle(enable: Boolean) {
-        enableScroll = enable
-        setAutoReadEnabled(enable)
-    }
-
-    override fun onAutoPageToggle2() {
-        val toggle = !enableScroll
-        setAutoReadEnabled(toggle)
-    }
-
-    //自动翻页速度
-    override fun onAutoPageSpeedChanged(speed: Int) {
-        setAutoReadEnabled(false)
-        updateMangaSetting { it.copy(autoPageSpeed = speed) }
-        mScrollTimer.setSpeed(speed)
-        setAutoReadEnabled(enableScroll)
-    }
-
-    override fun onMangaLongClickChanged(checked: Boolean) {
-        updateMangaSetting { it.copy(longClick = checked) }
-    }
-
-    override fun onVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting { it.copy(volumeKeyPage = enable) }
+        super.onPause()
     }
 
-    override fun onReverseVolumeKeyPageChanged(enable: Boolean) {
-        updateMangaSetting { it.copy(reverseVolumeKeyPage = enable) }
+    private fun finishReader() {
+        if (readerViewModel.uiState.value.inBookshelf && !isRestoredFromSavedState) supportFinishAfterTransition()
+        else finish()
     }
 
-    override fun onHideMangaTitleChanged(hide: Boolean) {
-        updateMangaSetting(afterUpdate = ReadManga::loadContent) { it.copy(hideTitle = hide) }
+    private fun openCatalog() {
+        readerViewModel.uiState.value.bookUrl.takeIf(String::isNotEmpty)?.let(tocActivity::launch)
     }
 
-    override fun openBookInfoActivity() {
-        ReadManga.book?.let {
+    private fun openBookInfoActivity() {
+        readerViewModel.uiState.value.let {
+            if (it.bookUrl.isEmpty()) return
             bookInfoActivity.launch {
-                putExtra("name", it.name)
-                putExtra("author", it.author)
+                putExtra("name", it.bookName)
+                putExtra("author", it.bookAuthor)
                 putExtra("bookUrl", it.bookUrl)
             }
         }
     }
 
-    //1 自动翻页 2 自动滚动 0 关闭
-    fun setAutoReadEnabled(enabled: Boolean) {
-        enableScroll = enabled
-        val isWebtoonMode =
-            scrollMode == MangaScrollMode.WEBTOON || scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
-        if (enabled) {
-            if (isWebtoonMode) {
-                mScrollTimer.isEnabled = true
-                enableAutoScroll = true
-                enableAutoScrollPage = false
-                mScrollTimer.isEnabledPage = false
-            } else {
-                mScrollTimer.isEnabledPage = true
-                enableAutoScrollPage = true
-                enableAutoScroll = false
-                mScrollTimer.isEnabled = false
-            }
-        } else {
-            enableAutoScroll = false
-            enableAutoScrollPage = false
-            mScrollTimer.isEnabled = false
-            mScrollTimer.isEnabledPage = false
-        }
-    }
-
-    override fun upSystemUiVisibility(menuIsVisible: Boolean) {
-        toggleSystemBar(menuIsVisible)
-        if (enableAutoScroll) {
-            mScrollTimer.isEnabled = !menuIsVisible
-        }
-        if (enableAutoScrollPage) {
-            mScrollTimer.isEnabledPage = !menuIsVisible
-        }
-    }
-
-    /**
-     * 调整漫画类型
-     * @param mode 漫画类型
-     */
-    @SuppressLint("NotifyDataSetChanged")
-    private fun setScrollMode(mode: Int) {
-        scrollMode = mode
-
-        val isHorizontalPage =
-            mode == MangaScrollMode.PAGE_LEFT_TO_RIGHT || mode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
-
-        val isWebtoon = mode == MangaScrollMode.WEBTOON || mode == MangaScrollMode.WEBTOON_WITH_GAP
-
-        when (mode) {
-            MangaScrollMode.PAGE_LEFT_TO_RIGHT -> {
-                mLayoutManager.orientation = LinearLayoutManager.HORIZONTAL
-                mLayoutManager.reverseLayout = false
-            }
-
-            MangaScrollMode.PAGE_RIGHT_TO_LEFT -> {
-                mLayoutManager.orientation = LinearLayoutManager.HORIZONTAL
-                mLayoutManager.reverseLayout = true
-            }
-
-            MangaScrollMode.PAGE_TOP_TO_BOTTOM,
-            MangaScrollMode.WEBTOON,
-            MangaScrollMode.WEBTOON_WITH_GAP -> {
-                mLayoutManager.orientation = LinearLayoutManager.VERTICAL
-                mLayoutManager.reverseLayout = false
-            }
-        }
-
-        mAdapter.isHorizontal = isHorizontalPage || mode == MangaScrollMode.PAGE_TOP_TO_BOTTOM
-
-        if (!isWebtoon) {
-            mPagerSnapHelper.attachToRecyclerView(binding.recyclerView)
-        } else {
-            mPagerSnapHelper.attachToRecyclerView(null)
-        }
-
-        mAdapter.notifyItemRangeChanged(0, mAdapter.itemCount)
-    }
-
-    /**
-     * 调整条漫侧边留白设置
-     * @param paddingDp 侧边留白距离,单位dp
-     */
-    private fun updateWebtoonSidePadding(paddingDp: Int) {
-        if (scrollMode != MangaScrollMode.WEBTOON && scrollMode != MangaScrollMode.WEBTOON_WITH_GAP) {
-            // 非条漫模式,取消留白
-            binding.recyclerView.setPadding(0, 0, 0, 0)
-            binding.recyclerView.clipToPadding = true
+    private fun openCurrentChapterUrl(externalBrowser: Boolean) {
+        val state = readerViewModel.uiState.value
+        val chapterUrl = state.chapterUrl ?: return
+        if (externalBrowser) {
+            openUrl(chapterUrl)
             return
         }
-
-        binding.recyclerView.doOnLayout { recyclerView ->
-            val width = recyclerView.width
-            if (paddingDp == 0) {
-                recyclerView.setPadding(0, 0, 0, 0)
-            } else if (width > 0) {
-                val paddingPx = (width * paddingDp / 100)
-                recyclerView.setPadding(paddingPx, 0, paddingPx, 0)
-            }
+        startActivity<WebViewActivity> {
+            putExtra("title", state.chapterName)
+            putExtra("url", chapterUrl)
+            putExtra("sourceOrigin", state.sourceUrl)
+            putExtra("sourceName", state.sourceName)
+            putExtra("sourceType", state.sourceType)
         }
-
-    }
-
-    private fun setBackground() {
-        binding.webtoonFrame.setBackgroundColor(viewModel.mangaSettings.value.background)
-        binding.recyclerView.setBackgroundColor(viewModel.mangaSettings.value.background)
-        mAdapter.notifyItemRangeChanged(0, mAdapter.itemCount)
     }
 
-
-    private fun setRecyclerViewPreloader(maxPreload: Int) {
-        if (mRecyclerViewPreloader != null) {
-            binding.recyclerView.removeOnScrollListener(mRecyclerViewPreloader!!)
+    private fun resetWindowToSystemBrightness() {
+        window.attributes = window.attributes.apply {
+            screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
         }
-        mRecyclerViewPreloader = RecyclerViewPreloader(
-            Glide.with(this), mAdapter, mSizeProvider, maxPreload
-        )
-        binding.recyclerView.addOnScrollListener(mRecyclerViewPreloader!!)
     }
 
-    @SuppressLint("StringFormatMatches")
-    private fun upMenu(menu: Menu) {
-        this.mMenu = menu
-        menu.findItem(R.id.menu_pre_manga_number).title =
-            getString(R.string.pre_download_m, viewModel.mangaSettings.value.preDownloadNum)
-    }
-
-    private fun setDisableMangaScale(disable: Boolean) {
-        binding.webtoonFrame.disableMangaScale = disable
-        binding.recyclerView.disableMangaScale = disable
-        if (disable) {
-            binding.recyclerView.resetZoom()
-        }
-    }
-
-    private fun upLayoutInDisplayCutoutMode() {
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
-            window.attributes = window.attributes.apply {
-                layoutInDisplayCutoutMode =
-                    WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
-            }
-        }
-    }
-
-    private fun scrollToNext() {
-        if (!viewModel.mangaSettings.value.disableClickScroll)
-            scrollPageTo(1)
-    }
-
-    private fun scrollToPrev() {
-        if (!viewModel.mangaSettings.value.disableClickScroll)
-            scrollPageTo(-1)
-    }
-
-    private fun isHorizontalScroll(): Boolean {
-        return scrollMode == MangaScrollMode.PAGE_LEFT_TO_RIGHT ||
-                scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
-    }
-
-    private fun scrollPageTo(direction: Int) {
-        if (!binding.recyclerView.canScroll(direction)) {
-            return
-        }
-        var dx = 0
-        var dy = 0
-        if (isHorizontalScroll()) {
-            dx = binding.recyclerView.run {
-                width - paddingStart - paddingEnd
-            }
-        } else {
-            dy = binding.recyclerView.run {
-                height - paddingTop - paddingBottom
-            }
-        }
-        dx *= direction
-        dy *= direction
-        if (!viewModel.mangaSettings.value.disableMangaScrollAnimation)
-            binding.recyclerView.smoothScrollBy(dx, dy)
-        else
-            binding.recyclerView.scrollBy(dx, dy)
-
-    }
-
-    private fun showNumberPickerDialog(
-        min: Int,
-        title: String,
-        initValue: Int,
-        callback: (Int) -> Unit,
-    ) {
-        NumberPickerDialog(this)
-            .setTitle(title)
-            .setMaxValue(9999)
-            .setMinValue(min)
-            .setValue(initValue)
-            .show {
-                callback.invoke(it)
-            }
-    }
-
-    fun resetWindowToSystemBrightness() {
-        val layoutParams = window.attributes
-        layoutParams.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
-        window.attributes = layoutParams
-    }
-
-    fun updateWindowBrightness(brightness: Int) {
-        val layoutParams = window.attributes
-        val normalizedBrightness = brightness.toFloat() / 255.0f
-        layoutParams.screenBrightness = normalizedBrightness.coerceIn(0f, 1f)
-        window.attributes = layoutParams
-        // 强制刷新屏幕
-        window.decorView.postInvalidate()
-    }
-
-    override fun skipToPage(index: Int) {
-        val durChapterIndex = ReadManga.durChapterIndex
-        val itemPos = mAdapter.getItems().fastBinarySearch {
-            val chapterIndex: Int
-            val pageIndex: Int
-            if (it is BaseMangaPage) {
-                chapterIndex = it.chapterIndex
-                pageIndex = it.index
-            } else {
-                error("unknown item type")
-            }
-            val delta = chapterIndex - durChapterIndex
-            if (delta != 0) {
-                delta
-            } else {
-                pageIndex - index
-            }
-        }
-        if (itemPos > -1) {
-            mLayoutManager.scrollToPositionWithOffset(itemPos, 0)
-            upInfoBar(mAdapter.getItem(itemPos))
-            ReadManga.durChapterPos = index
+    private fun updateWindowBrightness(brightness: Int) {
+        window.attributes = window.attributes.apply {
+            screenBrightness = (brightness / 255f).coerceIn(0f, 1f)
         }
     }
 
     override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
-        if (!viewModel.mangaSettings.value.volumeKeyPage) {
-            return super.onKeyDown(keyCode, event)
+        val settings = readerViewModel.uiState.value.settings
+        if (!settings.volumeKeyPage) return super.onKeyDown(keyCode, event)
+        val reverse = settings.reverseVolumeKeyPage
+        val direction = when (keyCode) {
+            KeyEvent.KEYCODE_VOLUME_UP -> if (reverse) 1 else -1
+            KeyEvent.KEYCODE_VOLUME_DOWN -> if (reverse) -1 else 1
+            else -> return super.onKeyDown(keyCode, event)
         }
-
-        val isReverse = viewModel.mangaSettings.value.reverseVolumeKeyPage
-
-        when (keyCode) {
-            KeyEvent.KEYCODE_VOLUME_UP -> {
-                if (isReverse) {
-                    scrollToNext()
-                } else {
-                    scrollToPrev()
-                }
-                return true
-            }
-
-            KeyEvent.KEYCODE_VOLUME_DOWN -> {
-                if (isReverse) {
-                    scrollToPrev()
-                } else {
-                    scrollToNext()
-                }
-                return true
-            }
-        }
-
-        return super.onKeyDown(keyCode, event)
+        readerViewModel.onIntent(MangaReaderIntent.PageStep(direction))
+        return true
     }
-
-    override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
-        viewModel.addToBookshelf(book, toc) {
-            toastOnUi("已添加到书架")
-        }
-    }
-
 }

+ 0 - 475
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -1,475 +0,0 @@
-package io.legado.app.ui.book.manga
-
-import android.app.Application
-import android.content.Intent
-import android.graphics.Bitmap
-import androidx.lifecycle.viewModelScope
-import com.bumptech.glide.Glide
-import io.legado.app.R
-import io.legado.app.base.BaseViewModel
-import io.legado.app.constant.AppLog
-import io.legado.app.constant.BookType
-import io.legado.app.constant.EventBus
-import io.legado.app.data.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.data.repository.BookRepository
-import io.legado.app.data.repository.BookSourceRepository
-import io.legado.app.domain.model.ReadingProgress
-import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.domain.usecase.GetReadingProgressUseCase
-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.coroutine.Coroutine
-import io.legado.app.model.ReadManga
-import io.legado.app.model.localBook.LocalBook
-import io.legado.app.model.webBook.WebBook
-import io.legado.app.ui.config.otherConfig.OtherConfig
-import io.legado.app.utils.ImageSaveUtils
-import io.legado.app.utils.mapParallelSafe
-import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.flow.onCompletion
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.onEmpty
-import kotlinx.coroutines.flow.onStart
-import kotlinx.coroutines.flow.take
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import splitties.init.appCtx
-import java.io.ByteArrayOutputStream
-import io.legado.app.ui.config.readConfig.ReadConfig as GlobalReadConfig
-
-class ReadMangaViewModel(
-    application: Application,
-    private val getReadingProgressUseCase: GetReadingProgressUseCase,
-    private val mangaSettingsGateway: MangaSettingsGateway,
-    private val otherSettingsGateway: OtherSettingsGateway,
-    private val readSettingsGateway: ReadSettingsGateway,
-    private val bookRepository: BookRepository,
-    private val bookSourceRepository: BookSourceRepository,
-) : BaseViewModel(application) {
-
-    private val _mangaSettings = MutableStateFlow(mangaSettingsGateway.currentSettings)
-    val mangaSettings = _mangaSettings.asStateFlow()
-
-    private var changeSourceCoroutine: Coroutine<*>? = null
-
-    init {
-        viewModelScope.launch {
-            mangaSettingsGateway.settings.collect { _mangaSettings.value = it }
-        }
-    }
-
-    suspend fun updateMangaSettings(transform: (MangaSettings) -> MangaSettings) {
-        mangaSettingsGateway.update(transform)
-    }
-
-    /**
-     * 初始化
-     */
-    fun initData(intent: Intent, success: (() -> Unit)? = null) {
-        execute {
-            ReadManga.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
-            ReadManga.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
-            val bookUrl = intent.getStringExtra("bookUrl")
-            val book = when {
-                bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
-                else -> bookRepository.getBook(bookUrl)
-            } ?: ReadManga.book
-            when {
-                book != null -> initManga(book)
-                else -> context.getString(R.string.no_book)//没有找到书
-            }
-        }.onSuccess {
-            success?.invoke()
-        }.onError {
-            val msg = "初始化数据失败\n${it.localizedMessage}"
-            AppLog.put(msg, it)
-        }.onFinally {
-            ReadManga.saveRead()
-        }
-    }
-
-    private suspend fun initManga(book: Book) {
-        val isSameBook = ReadManga.book?.bookUrl == book.bookUrl
-        if (isSameBook) {
-            ReadManga.upData(book)
-        } else {
-            ReadManga.resetData(book)
-        }
-        if (!book.isLocal && book.tocUrl.isEmpty() && !loadBookInfo(book)) {
-            return
-        }
-
-        if (book.isLocal && !checkLocalBookFileExist(book)) {
-            return
-        }
-
-        if ((ReadManga.chapterSize == 0 || book.isLocalModified()) && !loadChapterListAwait(book)) {
-            return
-        }
-
-        //开始加载内容
-        if (!isSameBook) {
-            ReadManga.loadContent()
-        } else {
-            ReadManga.loadOrUpContent()
-        }
-
-        if (ReadManga.chapterChanged) {
-            // 有章节跳转不同步阅读进度
-            ReadManga.chapterChanged = false
-        } else if (!isSameBook) {
-            if (GlobalReadConfig.syncBookProgressPlus) {
-                ReadManga.syncProgress(
-                    { progress -> ReadManga.mCallback?.sureNewProgress(progress) })
-            } else {
-                syncBookProgress(book)
-            }
-        }
-
-        //自动换源
-        if (!book.isLocal && ReadManga.bookSource == null) {
-            autoChangeSource(book.name, book.author)
-            return
-        }
-    }
-
-    fun upBookSource(success: (() -> Unit)?) {
-        execute {
-            ReadManga.book?.let { book ->
-                ReadManga.bookSource = bookSourceRepository.getBookSource(book.origin)
-            }
-        }.onSuccess {
-            success?.invoke()
-        }
-    }
-
-    fun loadChapterList(book: Book) {
-        execute {
-            loadChapterListAwait(book)
-        }
-    }
-
-    fun disableSource() {
-        execute {
-            ReadManga.bookSource?.let {
-                it.enabled = false
-                bookSourceRepository.updateSources(it)
-            }
-        }
-    }
-
-    private suspend fun loadChapterListAwait(book: Book): Boolean {
-        ReadManga.bookSource?.let {
-            val oldBook = book.copy()
-            WebBook.getChapterListAwait(it, book, true).onSuccess { cList ->
-                if (oldBook.bookUrl == book.bookUrl) {
-                    bookRepository.update(book)
-                } else {
-                    bookRepository.replace(oldBook, book)
-                    BookHelp.updateCacheFolder(oldBook, book)
-                }
-                bookRepository.deleteChaptersByBook(oldBook.bookUrl)
-                bookRepository.insertChapters(*cList.toTypedArray())
-                ReadManga.onChapterListUpdated(book)
-                return true
-            }.onFailure {
-                //加载章节出错
-                ReadManga.mCallback?.loadFail(appCtx.getString(R.string.error_load_toc))
-                return false
-            }
-        }
-
-        return true
-
-    }
-
-    /**
-     * 加载详情页
-     */
-    private suspend fun loadBookInfo(book: Book): Boolean {
-        val source = ReadManga.bookSource ?: return true
-        try {
-            WebBook.getBookInfoAwait(source, book, canReName = false)
-            return true
-        } catch (e: Throwable) {
-            ReadManga.mCallback?.loadFail("详情页出错: ${e.localizedMessage}")
-            return false
-        }
-    }
-
-    /**
-     * 自动换源
-     */
-    private fun autoChangeSource(name: String, author: String) {
-        if (!GlobalReadConfig.autoChangeSource) return
-        execute {
-            val sources = bookSourceRepository.getAllTextEnabledPart()
-            flow {
-                for (source in sources) {
-                    source.getBookSource()?.let {
-                        emit(it)
-                    }
-                }
-            }.onStart {
-                // 自动换源
-
-        }.mapParallelSafe(OtherConfig.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(
-                    bookSource = source,
-                    book = book,
-                    bookChapter = chapter,
-                    nextChapterUrl = nextChapter.url
-                )
-                book to toc
-            }.take(1).onEach { (book, toc) ->
-                changeTo(book, toc)
-            }.onEmpty {
-                throw NoStackTraceException("没有合适书源")
-            }.onCompletion {
-                // 换源完成
-            }.catch {
-                AppLog.put("自动换源失败\n${it.localizedMessage}", it)
-                context.toastOnUi("自动换源失败\n${it.localizedMessage}")
-            }.collect()
-        }
-    }
-
-    /**
-     * 同步进度
-     */
-    fun syncBookProgress(
-        book: Book,
-        alertSync: ((progress: BookProgress) -> Unit)? = null
-    ) {
-        if (!GlobalReadConfig.syncBookProgress) return
-        execute {
-            getReadingProgressUseCase.execute(book.name, book.author)?.toBookProgress()
-        }.onError {
-            AppLog.put("拉取阅读进度失败《${book.name}》\n${it.localizedMessage}", it)
-        }.onSuccess { progress ->
-            progress ?: return@onSuccess
-            if (progress.durChapterIndex < book.durChapterIndex ||
-                (progress.durChapterIndex == book.durChapterIndex
-                        && progress.durChapterPos < book.durChapterPos)
-            ) {
-                alertSync?.invoke(progress)
-            } else if (progress.durChapterIndex < book.simulatedTotalChapterNum()) {
-                ReadManga.setProgress(progress)
-                AppLog.put("自动同步阅读进度成功《${book.name}》 ${progress.durChapterTitle}")
-            }
-        }
-    }
-
-    private fun ReadingProgress.toBookProgress() = BookProgress(
-        name = name,
-        author = author,
-        durChapterIndex = durChapterIndex,
-        durChapterPos = durChapterPos,
-        durChapterTime = durChapterTime,
-        durChapterTitle = durChapterTitle
-    )
-
-    /**
-     * 换源
-     */
-    fun changeTo(book: Book, toc: List<BookChapter>) {
-        changeSourceCoroutine?.cancel()
-        changeSourceCoroutine = execute {
-            //换源中
-            ReadManga.book?.migrateTo(
-                book,
-                toc,
-                otherSettingsGateway.currentSettings.replaceEnableDefault,
-                readSettingsGateway.currentSettings.chineseConverterType,
-            )
-            book.removeType(BookType.updateError)
-            ReadManga.book?.delete()
-            bookRepository.insert(book)
-            bookRepository.insertChapters(*toc.toTypedArray())
-            ReadManga.resetData(book)
-            ReadManga.loadContent()
-        }.onError {
-            AppLog.put("换源失败\n$it", it, true)
-        }.onFinally {
-            postEvent(EventBus.SOURCE_CHANGED, book.bookUrl)
-        }
-    }
-
-    private fun checkLocalBookFileExist(book: Book): Boolean {
-        try {
-            LocalBook.getBookInputStream(book)
-            return true
-        } catch (e: Throwable) {
-            return false
-        }
-    }
-
-    fun openChapter(index: Int, durChapterPos: Int = 0) {
-        if (index < ReadManga.chapterSize) {
-            ReadManga.showLoading()
-            ReadManga.durChapterIndex = index
-            ReadManga.durChapterPos = durChapterPos
-            ReadManga.saveRead()
-            ReadManga.loadContent()
-        }
-    }
-
-    fun removeCurrentNotShelfBook(
-        success: () -> Unit,
-        onFailure: () -> Unit = {},
-    ) {
-        val book = ReadManga.book
-        val bookUrl = book?.bookUrl
-        Coroutine.async {
-            book?.delete()
-        }.onSuccess {
-            if (ReadManga.book?.bookUrl == bookUrl) {
-                ReadManga.book = null
-            }
-            success()
-        }.onError {
-            AppLog.put("移除临时漫画书籍失败", it)
-            context.toastOnUi("移除临时漫画书籍失败")
-            onFailure()
-        }
-    }
-
-
-    fun getEffectiveScrollMode(): Int {
-        return ReadManga.book?.readConfig?.mangaScrollMode
-            ?: mangaSettings.value.scrollMode
-    }
-
-    fun getEffectiveWebtoonSidePadding(): Int {
-        return ReadManga.book?.readConfig?.webtoonSidePaddingDp
-            ?: mangaSettings.value.webtoonSidePaddingDp
-    }
-
-    fun saveImageToGallery(url: String, folderName: String = "Legado") {
-        viewModelScope.launch {
-            try {
-                val byteArray = withContext(Dispatchers.IO) {
-                    // 用 Glide 下载 Bitmap 转换成 ByteArray
-                    val bitmap = Glide.with(getApplication<Application>().applicationContext)
-                        .asBitmap()
-                        .load(url)
-                        .submit()
-                        .get()
-                    val baos = ByteArrayOutputStream()
-                    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, baos)
-                    baos.toByteArray()
-                }
-
-                val context = getApplication<Application>().applicationContext
-                val success = ImageSaveUtils.saveImageToGallery(
-                    context,
-                    byteArray,
-                    folderName = folderName
-                )
-
-                if (success) {
-                    context.toastOnUi("图片已保存到相册")
-                } else {
-                    context.toastOnUi("保存失败")
-                }
-            } catch (e: Exception) {
-                context.toastOnUi("保存出错: ${e.localizedMessage}")
-            }
-        }
-    }
-
-
-    fun updateReadConfig(update: ReadConfig.() -> Unit) {
-        val book = ReadManga.book ?: return
-        val config = book.readConfig ?: ReadConfig()
-        config.update()
-        book.readConfig = config
-
-        execute {
-            bookRepository.update(book)
-        }.onError {
-            AppLog.put("更新阅读配置失败\n${it.localizedMessage}", it)
-        }
-    }
-
-    override fun onCleared() {
-        super.onCleared()
-        changeSourceCoroutine?.cancel()
-    }
-
-    fun refreshContentDur(book: Book) {
-        execute {
-            bookRepository.getChapter(book.bookUrl, ReadManga.durChapterIndex)
-                ?.let { chapter ->
-                    BookHelp.delContent(book, chapter)
-                    openChapter(ReadManga.durChapterIndex, ReadManga.durChapterPos)
-                }
-        }
-    }
-
-    fun addCurrentBookToBookshelf(
-        success: () -> Unit,
-        onFailure: () -> Unit = {},
-    ) {
-        val book = ReadManga.book ?: return onFailure()
-        execute {
-            val toc = bookRepository.getChapters(book.bookUrl)
-            persistOnBookshelf(book, toc)
-            ReadManga.inBookshelf = true
-        }.onSuccess {
-            success()
-        }.onError {
-            AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
-            onFailure()
-        }
-    }
-
-    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
-        execute {
-            persistOnBookshelf(book, toc)
-        }.onSuccess {
-            success?.invoke()
-        }.onError {
-            AppLog.put("添加书籍到书架失败", it)
-            context.toastOnUi("添加书籍失败")
-        }
-    }
-
-    private suspend fun persistOnBookshelf(book: Book, toc: List<BookChapter>) {
-        book.removeType(BookType.notShelf)
-        if (book.order == 0) {
-            book.order = bookRepository.getMinOrder() - 1
-        }
-        bookRepository.insert(book)
-        bookRepository.insertChapters(*toc.toTypedArray())
-    }
-}

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

@@ -1,55 +0,0 @@
-package io.legado.app.ui.book.manga.config
-
-import android.os.Bundle
-import android.view.View
-import io.legado.app.R
-import io.legado.app.base.BaseBottomSheetDialogFragment
-import io.legado.app.databinding.DialogMangaAutoReadBinding
-import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import org.koin.android.ext.android.inject
-
-class MangaAutoReadDialog : BaseBottomSheetDialogFragment(R.layout.dialog_manga_auto_read) {
-
-    private val binding by viewBinding(DialogMangaAutoReadBinding::bind)
-    private val mangaSettingsGateway: MangaSettingsGateway by inject()
-
-    var initialAutoPageEnabled: Boolean = false
-
-    var callback: Callback? = null
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        initView()
-    }
-
-    private fun initView() {
-
-        binding.chipAutoPage.run {
-            isChecked = initialAutoPageEnabled
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onAutoPageToggle(isChecked)
-                initAutoPageSpeed(isChecked)
-            }
-        }
-
-        initAutoPageSpeed(initialAutoPageEnabled)
-    }
-
-    private fun initAutoPageSpeed(boolean: Boolean) {
-        binding.scvAutoPageSpeed.run {
-            isEnabled = boolean
-            valueFormat = { "$it 秒" }
-            progress = mangaSettingsGateway.currentSettings.autoPageSpeed
-
-            onChanged = { newValue ->
-                callback?.onAutoPageSpeedChanged(newValue)
-            }
-        }
-    }
-
-    interface Callback {
-        fun onAutoPageToggle(enable: Boolean)
-        fun onAutoPageSpeedChanged(speed: Int)
-    }
-
-}

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

@@ -1,168 +0,0 @@
-package io.legado.app.ui.book.manga.config
-
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.BaseOverlayDialogFragment
-import io.legado.app.databinding.DialogClickActionConfigBinding
-import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.lib.dialogs.selector
-import io.legado.app.utils.getCompatColor
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.launch
-import org.koin.android.ext.android.inject
-
-class MangaClickActionConfigDialog : BaseOverlayDialogFragment(R.layout.dialog_click_action_config) {
-    private val binding by viewBinding(DialogClickActionConfigBinding::bind)
-    private val mangaSettingsGateway: MangaSettingsGateway by inject()
-
-    private val actions by lazy {
-        linkedMapOf(
-            Pair(-1, getString(R.string.non_action)),
-            Pair(0, getString(R.string.menu)),
-            Pair(1, getString(R.string.next_page)),
-            Pair(2, getString(R.string.prev_page)),
-            Pair(3, getString(R.string.next_chapter)),
-            Pair(4, getString(R.string.previous_chapter)),
-        )
-    }
-
-    override fun onStart() {
-        super.onStart()
-        dialog?.window?.run {
-            setBackgroundDrawableResource(R.color.transparent)
-            setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
-        }
-    }
-
-    override fun onDismiss(dialog: DialogInterface) {
-        super.onDismiss(dialog)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        view.setBackgroundColor(getCompatColor(R.color.translucent))
-        initData()
-        initViewEvent()
-    }
-
-    private fun initData() = binding.run {
-        tvTopLeft.text = actions[mangaSettingsGateway.currentSettings.clickActionTL]
-        tvTopCenter.text = actions[mangaSettingsGateway.currentSettings.clickActionTC]
-        tvTopRight.text = actions[mangaSettingsGateway.currentSettings.clickActionTR]
-        tvMiddleLeft.text = actions[mangaSettingsGateway.currentSettings.clickActionML]
-        tvMiddleCenter.text = actions[mangaSettingsGateway.currentSettings.clickActionMC]
-        tvMiddleRight.text = actions[mangaSettingsGateway.currentSettings.clickActionMR]
-        tvBottomLeft.text = actions[mangaSettingsGateway.currentSettings.clickActionBL]
-        tvBottomCenter.text = actions[mangaSettingsGateway.currentSettings.clickActionBC]
-        tvBottomRight.text = actions[mangaSettingsGateway.currentSettings.clickActionBR]
-    }
-
-    private fun initViewEvent() {
-        binding.ivClose.setOnClickListener {
-            dismissAllowingStateLoss()
-        }
-
-        binding.tvTopLeft.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionTL = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvTopCenter.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionTC = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvTopRight.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionTR = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvMiddleLeft.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionML = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvMiddleCenter.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionMC = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvMiddleRight.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionMR = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvBottomLeft.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionBL = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvBottomCenter.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionBC = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-
-        binding.tvBottomRight.setOnClickListener {
-            selectAction { action ->
-                setClickAction { it.copy(clickActionBR = action) }
-                (it as? TextView)?.text = actions[action]
-            }
-        }
-    }
-
-    private fun setClickAction(transform: (MangaSettings) -> MangaSettings) {
-        lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-            mangaSettingsGateway.update(transform)
-        }
-    }
-
-    private fun selectAction(success: (action: Int) -> Unit) {
-        context?.selector(
-            getString(R.string.select_action),
-            actions.values.toList()
-        ) { _, index ->
-            success.invoke(actions.keys.toList()[index])
-        }
-    }
-
-    override fun onDestroy() {
-        if (!hasMenuClickArea()) {
-            lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
-                mangaSettingsGateway.update { it.copy(clickActionMC = 0) }
-            }
-            context?.toastOnUi("当前没有配置菜单区域,自动恢复中间区域为菜单.")
-        }
-        super.onDestroy()
-    }
-
-    private fun hasMenuClickArea(): Boolean {
-        return mangaSettingsGateway.currentSettings.clickActionTL * mangaSettingsGateway.currentSettings.clickActionTC *
-                mangaSettingsGateway.currentSettings.clickActionTR * mangaSettingsGateway.currentSettings.clickActionML *
-                mangaSettingsGateway.currentSettings.clickActionMC * mangaSettingsGateway.currentSettings.clickActionMR *
-                mangaSettingsGateway.currentSettings.clickActionBL * mangaSettingsGateway.currentSettings.clickActionBC *
-                mangaSettingsGateway.currentSettings.clickActionBR == 0
-    }
-}

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

@@ -1,132 +0,0 @@
-package io.legado.app.ui.book.manga.config
-
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.View
-import android.view.WindowManager
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.BaseBottomSheetDialogFragment
-import io.legado.app.databinding.DialogMangaColorFilterBinding
-import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.invisible
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import kotlinx.coroutines.launch
-import org.koin.android.ext.android.inject
-
-class MangaColorFilterDialog : BaseBottomSheetDialogFragment(R.layout.dialog_manga_color_filter) {
-    private val binding by viewBinding(DialogMangaColorFilterBinding::bind)
-    private val mangaSettingsGateway: MangaSettingsGateway by inject()
-    private val mConfig =
-        GSON.fromJsonObject<MangaColorFilterConfig>(mangaSettingsGateway.currentSettings.colorFilter).getOrNull()
-            ?: MangaColorFilterConfig()
-    private val callback: Callback? get() = activity as? Callback
-
-    private var mMangaEInkThreshold = mangaSettingsGateway.currentSettings.eInkThreshold
-
-
-    override fun onStart() {
-        super.onStart()
-        dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-        initData()
-        initView()
-    }
-
-    private fun initData() {
-        binding.run {
-            chipAutoBrightness.isChecked = mConfig.autoBrightness
-
-            if (mangaSettingsGateway.currentSettings.enableEInk) dsbEpaper.visible()
-            else dsbEpaper.invisible()
-
-            dsbEpaper.progress = mMangaEInkThreshold
-
-            cpEpaper.isChecked = mangaSettingsGateway.currentSettings.enableEInk
-            cpEnableGray.isChecked = mangaSettingsGateway.currentSettings.enableGray
-
-            dsbBrightness.isEnabled = !mConfig.autoBrightness
-            dsbBrightness.progress = mConfig.l
-            dsbR.progress = mConfig.r
-            dsbG.progress = mConfig.g
-            dsbB.progress = mConfig.b
-            dsbA.progress = mConfig.a
-        }
-    }
-
-    private fun initView() {
-        binding.run {
-            chipAutoBrightness.setOnCheckedChangeListener { _, isChecked ->
-                mConfig.autoBrightness = isChecked
-                dsbBrightness.isEnabled = !isChecked
-                callback?.updateColorFilter(mConfig)
-            }
-
-            cpEpaper.setOnCheckedChangeListener { _, isChecked ->
-                binding.dsbEpaper.isVisible = isChecked
-                if (isChecked) {
-                    cpEnableGray.isChecked = false
-                    callback?.updateGrayMode(false)
-                    dsbEpaper.visible()
-                } else dsbEpaper.invisible()
-                callback?.updateEpaperMode(isChecked, mMangaEInkThreshold)
-            }
-
-            cpEnableGray.setOnCheckedChangeListener { _, isChecked ->
-                if (isChecked) {
-                    binding.dsbEpaper.isVisible = false
-                    cpEpaper.isChecked = false
-                    callback?.updateEpaperMode(false, mMangaEInkThreshold)
-                }
-                dsbEpaper.invisible()
-                callback?.updateGrayMode(isChecked)
-            }
-
-            dsbEpaper.onChanged = {
-                mMangaEInkThreshold = it
-                callback?.updateEpaperMode(true, it)
-            }
-
-            dsbBrightness.onChanged = {
-                mConfig.l = it
-                callback?.updateColorFilter(mConfig)
-            }
-            dsbR.onChanged = {
-                mConfig.r = it
-                callback?.updateColorFilter(mConfig)
-            }
-            dsbG.onChanged = {
-                mConfig.g = it
-                callback?.updateColorFilter(mConfig)
-            }
-            dsbB.onChanged = {
-                mConfig.b = it
-                callback?.updateColorFilter(mConfig)
-            }
-            dsbA.onChanged = {
-                mConfig.a = it
-                callback?.updateColorFilter(mConfig)
-            }
-        }
-    }
-
-    override fun onDismiss(dialog: DialogInterface) {
-        super.onDismiss(dialog)
-        lifecycleScope.launch {
-            mangaSettingsGateway.update { it.copy(colorFilter = mConfig.toJson()) }
-        }
-    }
-
-    interface Callback {
-        fun updateColorFilter(config: MangaColorFilterConfig)
-        fun updateEpaperMode(enabled: Boolean, threshold: Int)
-        fun updateGrayMode(enabled: Boolean)
-    }
-
-}

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

@@ -1,322 +0,0 @@
-package io.legado.app.ui.book.manga.config
-
-import android.content.DialogInterface
-import android.os.Bundle
-import android.view.Gravity
-import android.view.View
-import androidx.core.view.isVisible
-import androidx.lifecycle.lifecycleScope
-import androidx.transition.TransitionManager
-import com.google.android.material.chip.Chip
-import com.jaredrummler.android.colorpicker.ColorPickerDialog
-import io.legado.app.R
-import io.legado.app.base.BaseBottomSheetDialogFragment
-import io.legado.app.constant.EventBus
-import io.legado.app.databinding.DialogMangaFooterSettingBinding
-import io.legado.app.domain.gateway.MangaSettingsGateway
-import io.legado.app.ui.book.manga.entities.MangaFooterConfig
-import io.legado.app.ui.widget.ReaderInfoBarView
-import io.legado.app.utils.GSON
-import io.legado.app.utils.fromJsonObject
-import io.legado.app.utils.postEvent
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import kotlinx.coroutines.launch
-import org.koin.android.ext.android.inject
-
-class MangaFooterSettingDialog :
-    BaseBottomSheetDialogFragment(R.layout.dialog_manga_footer_setting) {
-
-    companion object {
-        const val MANGA_B = 1919
-    }
-
-    private val mangaSettingsGateway: MangaSettingsGateway by inject()
-    val config = GSON.fromJsonObject<MangaFooterConfig>(mangaSettingsGateway.currentSettings.footerConfig).getOrNull()
-        ?: MangaFooterConfig()
-
-    var initialWebtoonSidePadding: Int = 0
-    var initialScrollMode: Int = MangaScrollMode.WEBTOON
-
-    var callback: Callback? = null
-
-    private val binding by viewBinding(DialogMangaFooterSettingBinding::bind)
-
-    override fun onStart() {
-        super.onStart()
-
-    }
-
-    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
-
-        binding.chipGroupScrollMode.removeAllViews()
-
-        MangaScrollMode.ALL.forEach { mode ->
-            binding.chipGroupScrollMode.addView(Chip(requireContext()).apply {
-                text = MangaScrollMode.labelOf(mode)
-                isCheckable = true
-                isChecked = (mode == initialScrollMode)
-
-                setOnClickListener {
-                    callback?.onScrollModeChanged(mode)
-                    TransitionManager.beginDelayedTransition(binding.rootView)
-                    binding.llWebtoon.isVisible = mode == MangaScrollMode.WEBTOON ||
-                            mode == MangaScrollMode.WEBTOON_WITH_GAP
-                }
-                setOnLongClickListener {
-                    lifecycleScope.launch {
-                        mangaSettingsGateway.update { it.copy(scrollMode = mode) }
-                    }
-                    toastOnUi("已设置为全局默认模式")
-                    true
-                }
-            })
-        }
-
-        binding.btnBackgroundColor.color = mangaSettingsGateway.currentSettings.background
-        binding.btnBackgroundColor.setOnClickListener {
-            dismiss()
-            ColorPickerDialog.newBuilder()
-                .setColor(mangaSettingsGateway.currentSettings.background)
-                .setShowAlphaSlider(false)
-                .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
-                .setDialogId(MANGA_B)
-                .show(requireActivity())
-        }
-
-        binding.llWebtoon.isVisible = initialScrollMode == MangaScrollMode.WEBTOON ||
-                initialScrollMode == MangaScrollMode.WEBTOON_WITH_GAP
-
-        binding.scvPadding.apply {
-            valueFormat = { "$it %" }
-            progress = initialWebtoonSidePadding
-            onChanged = { newValue ->
-                callback?.upSidePadding(newValue)
-            }
-        }
-
-        binding.checkboxDisableClickScroll.apply {
-            isChecked = mangaSettingsGateway.currentSettings.disableClickScroll
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onClickScrollDisabledChanged(isChecked)
-            }
-        }
-
-        binding.checkboxScrollAnimation.apply {
-            isChecked = mangaSettingsGateway.currentSettings.disableMangaScrollAnimation
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onScrollAniDisabledChanged(isChecked)
-            }
-        }
-
-        binding.checkboxMangaCrossFade.apply {
-            isChecked = mangaSettingsGateway.currentSettings.disableMangaCrossFade
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onCrossFadeDisabledChanged(isChecked)
-            }
-        }
-
-        binding.checkboxDisableMangaScale.apply {
-            isChecked = mangaSettingsGateway.currentSettings.disableMangaScale
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onMangaScaleDisabledChanged(isChecked)
-            }
-        }
-
-        binding.checkboxHideMangaTitle.apply {
-            isChecked = mangaSettingsGateway.currentSettings.hideTitle
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onHideMangaTitleChanged(isChecked)
-            }
-        }
-
-        binding.checkboxVolumeKeyPage.apply {
-            isChecked = mangaSettingsGateway.currentSettings.volumeKeyPage
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onVolumeKeyPageChanged(isChecked)
-            }
-        }
-
-        binding.reverseVolumeKeyPage.apply {
-            isChecked = mangaSettingsGateway.currentSettings.reverseVolumeKeyPage
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onReverseVolumeKeyPageChanged(isChecked)
-            }
-        }
-
-        binding.checkboxMangaLongClick.apply {
-            isChecked = mangaSettingsGateway.currentSettings.longClick
-            setOnCheckedChangeListener { _, isChecked ->
-                callback?.onMangaLongClickChanged(isChecked)
-            }
-        }
-
-        binding.cbChapterLabel.run {
-            isChecked = !config.hideChapterLabel
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hideChapterLabel = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (isChecked) binding.cbChapter.isChecked = true
-            }
-        }
-
-        binding.cbChapter.run {
-            isChecked = !config.hideChapter
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hideChapter = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (!isChecked) binding.cbChapterLabel.isChecked = false
-            }
-        }
-
-        binding.cbChapterName.run {
-            isChecked = !config.hideChapterName
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hideChapterName = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-            }
-        }
-
-        binding.cbPageNumberLabel.run {
-            isChecked = !config.hidePageNumberLabel
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hidePageNumberLabel = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (isChecked) binding.cbPageNumber.isChecked = true
-            }
-        }
-
-        binding.cbPageNumber.run {
-            isChecked = !config.hidePageNumber
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hidePageNumber = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (!isChecked) binding.cbPageNumberLabel.isChecked = false
-            }
-        }
-
-        binding.cbProgressRatioLabel.run {
-            isChecked = !config.hideProgressRatioLabel
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hideProgressRatioLabel = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (isChecked) binding.cbProgressRatio.isChecked = true
-            }
-        }
-
-        binding.cbProgressRatio.run {
-            isChecked = !config.hideProgressRatio
-            setOnCheckedChangeListener { _, isChecked ->
-                config.hideProgressRatio = !isChecked
-                postEvent(EventBus.UP_MANGA_CONFIG, config)
-                updateChapterText()
-                if (!isChecked) binding.cbProgressRatioLabel.isChecked = false
-            }
-        }
-
-        binding.rgFooterOrientation.check(
-            when {
-                config.hideFooter -> R.id.rb_hide
-                config.footerOrientation == ReaderInfoBarView.ALIGN_CENTER -> R.id.rb_center
-                else -> R.id.rb_left
-            }
-        )
-
-        binding.rgFooterOrientation.setOnCheckedStateChangeListener { _, checkedIds ->
-            when (checkedIds.firstOrNull()) {
-                R.id.rb_left -> {
-                    config.hideFooter = false
-                    config.footerOrientation = ReaderInfoBarView.ALIGN_LEFT
-                }
-                R.id.rb_center -> {
-                    config.hideFooter = false
-                    config.footerOrientation = ReaderInfoBarView.ALIGN_CENTER
-                }
-                R.id.rb_hide -> {
-                    config.hideFooter = true
-                }
-            }
-            updateChapterText()
-            postEvent(EventBus.UP_MANGA_CONFIG, config)
-        }
-
-        binding.btnColorFilter.setOnClickListener {
-            callback?.showColorFilterConfig()
-            dismiss()
-        }
-
-        binding.btnClickSet.setOnClickListener {
-            callback?.showClickConfig()
-            dismiss()
-        }
-
-        updateChapterText()
-    }
-
-    override fun onDismiss(dialog: DialogInterface) {
-        super.onDismiss(dialog)
-        lifecycleScope.launch {
-            mangaSettingsGateway.update { it.copy(footerConfig = GSON.toJson(config)) }
-        }
-    }
-
-    private fun updateChapterText() {
-        if (config.hideFooter) {
-            binding.tvChapter.visibility = View.INVISIBLE
-            return
-        } else {
-            binding.tvChapter.visibility = View.VISIBLE
-        }
-
-        binding.tvChapter.gravity = when (config.footerOrientation) {
-            ReaderInfoBarView.ALIGN_LEFT -> Gravity.START or Gravity.CENTER_VERTICAL
-            ReaderInfoBarView.ALIGN_CENTER -> Gravity.CENTER
-            else -> Gravity.START
-        }
-
-        val label = if (!config.hideChapterLabel) "章节" else ""
-        val chapter = if (!config.hideChapter) "1/45 " else ""
-        val name = if (!config.hideChapterName) "第三话 " else ""
-
-        val progressLabel = if (!config.hideProgressRatioLabel) "总进度" else ""
-        val progress = if (!config.hideProgressRatio) "2.1%" else ""
-
-        val pageLabel = if (!config.hidePageNumberLabel) "页数" else ""
-        val page = if (!config.hidePageNumber) "4/30 " else ""
-
-        val parts = listOf(
-            name,
-            pageLabel,
-            page,
-            label,
-            chapter,
-            progressLabel,
-            progress
-        ).filter { it.isNotEmpty() }
-        binding.tvChapter.text = parts.joinToString(" ")
-    }
-
-    interface Callback {
-        fun onAutoPageToggle(enable: Boolean)
-        fun onAutoPageSpeedChanged(speed: Int)
-        fun showColorFilterConfig()
-        fun showClickConfig()
-        fun onScrollModeChanged(mode: Int)
-        fun upSidePadding(padding: Int)
-        fun onClickScrollDisabledChanged(disabled: Boolean)
-        fun onScrollAniDisabledChanged(disabled: Boolean)
-        fun onCrossFadeDisabledChanged(disabled: Boolean)
-        fun onMangaScaleDisabledChanged(disabled: Boolean)
-        fun onHideMangaTitleChanged(hide: Boolean)
-        fun onVolumeKeyPageChanged(enable: Boolean)
-        fun onReverseVolumeKeyPageChanged(enable: Boolean)
-        fun onMangaLongClickChanged(checked: Boolean)
-    }
-
-}

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

@@ -7,21 +7,5 @@ object MangaScrollMode {
     const val WEBTOON = 4                 // 条漫
     const val WEBTOON_WITH_GAP = 5        // 条漫(页面有空隙)
 
-    val ALL = listOf(
-        PAGE_LEFT_TO_RIGHT,
-        PAGE_RIGHT_TO_LEFT,
-        PAGE_TOP_TO_BOTTOM,
-        WEBTOON,
-        WEBTOON_WITH_GAP
-    )
-
-    fun labelOf(mode: Int): String = when (mode) {
-        PAGE_LEFT_TO_RIGHT -> "单页式(从左到右)"
-        PAGE_RIGHT_TO_LEFT -> "单页式(从右到左)"
-        PAGE_TOP_TO_BOTTOM -> "单页式(从上到下)"
-        WEBTOON -> "条漫"
-        WEBTOON_WITH_GAP -> "条漫(页面有空隙)"
-        else -> "未知"
-    }
 }
 

+ 0 - 79
app/src/main/java/io/legado/app/ui/book/manga/entities/EpaperTransformation.kt

@@ -1,79 +0,0 @@
-package io.legado.app.ui.book.manga.entities
-
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.ColorMatrix
-import android.graphics.ColorMatrixColorFilter
-import android.graphics.Paint
-import androidx.annotation.IntRange
-import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
-import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
-import java.security.MessageDigest
-
-/**
- * 墨水屏图片转换器。
- * 将彩色图片转换为灰度图,并可选择进行简单的二值化处理,以提高墨水屏显示效果。
- *
- * @param threshold 二值化的阈值(0-255)。低于此值的像素变为黑色,高于此值的像素变为白色。
- *                  仅当applyBinarization为true时有效。
- */
-class EpaperTransformation(
-    @param:IntRange(0, 255) private val threshold: Int = 128,
-) : BitmapTransformation() {
-
-    private val ID get() = "io.legado.app.model.EpaperTransformation.$threshold"
-    private val ID_BYTES get() = ID.toByteArray(CHARSET)
-
-    override fun transform(
-        pool: BitmapPool,
-        toTransform: Bitmap,
-        outWidth: Int,
-        outHeight: Int,
-    ): Bitmap {
-        val resultBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
-        val canvas = Canvas(resultBitmap)
-        val paint = Paint()
-
-        val colorMatrix = ColorMatrix()
-        colorMatrix.setSaturation(0f)
-        val filter = ColorMatrixColorFilter(colorMatrix)
-        paint.colorFilter = filter
-        canvas.drawBitmap(toTransform, 0f, 0f, paint)
-
-        val pixels = IntArray(outWidth * outHeight)
-        resultBitmap.getPixels(pixels, 0, outWidth, 0, 0, outWidth, outHeight)
-
-        for (i in pixels.indices) {
-            val pixel = pixels[i]
-            val gray = Color.red(pixel)
-            pixels[i] =
-                if (gray < threshold) Color.BLACK else Color.WHITE
-        }
-        resultBitmap.setPixels(pixels, 0, outWidth, 0, 0, outWidth, outHeight)
-
-        return resultBitmap
-    }
-
-    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
-        messageDigest.update(ID_BYTES)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-
-        other as EpaperTransformation
-
-        if (threshold != other.threshold) return false
-        if (ID != other.ID) return false
-
-        return true
-    }
-
-    override fun hashCode(): Int {
-        var result = 31 + threshold
-        result = 31 * result + ID.hashCode()
-        return result
-    }
-}

+ 0 - 57
app/src/main/java/io/legado/app/ui/book/manga/entities/GrayscaleTransformation.kt

@@ -1,57 +0,0 @@
-package io.legado.app.ui.book.manga.entities
-
-import android.graphics.Bitmap
-import android.graphics.Canvas
-import android.graphics.ColorMatrix
-import android.graphics.ColorMatrixColorFilter
-import android.graphics.Paint
-import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
-import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
-import java.nio.charset.StandardCharsets
-import java.security.MessageDigest
-
-class GrayscaleTransformation : BitmapTransformation() {
-    private val ID = "io.legado.app.model.GrayscaleTransformation"
-
-    private val ID_BYTES = ID.toByteArray(StandardCharsets.UTF_8)
-
-    override fun transform(
-        pool: BitmapPool,
-        toTransform: Bitmap,
-        outWidth: Int,
-        outHeight: Int,
-    ): Bitmap {
-        val resultBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
-
-        val canvas = Canvas(resultBitmap)
-        val paint = Paint()
-
-        val matrix = ColorMatrix(
-            floatArrayOf(
-                0.299f, 0.587f, 0.114f, 0f, 0f,
-                0.299f, 0.587f, 0.114f, 0f, 0f,
-                0.299f, 0.587f, 0.114f, 0f, 0f,
-                0f, 0f, 0f, 1f, 0f
-            )
-        )
-        val filter = ColorMatrixColorFilter(matrix)
-        paint.colorFilter = filter
-        canvas.drawBitmap(toTransform, 0f, 0f, paint)
-        return resultBitmap
-    }
-
-    override fun updateDiskCacheKey(messageDigest: MessageDigest) {
-        messageDigest.update(ID_BYTES)
-    }
-
-    override fun equals(other: Any?): Boolean {
-        if (this === other) return true
-        if (javaClass != other?.javaClass) return false
-        other as GrayscaleTransformation
-        return ID == other.ID
-    }
-
-    override fun hashCode(): Int {
-        return ID.hashCode()
-    }
-}

+ 0 - 8
app/src/main/java/io/legado/app/ui/book/manga/entities/MangaContent.kt

@@ -1,8 +0,0 @@
-package io.legado.app.ui.book.manga.entities
-
-data class MangaContent(
-    val pos: Int,
-    val items: List<Any>,
-    val curFinish: Boolean,
-    val nextFinish: Boolean
-)

+ 0 - 10
app/src/main/java/io/legado/app/ui/book/manga/entities/MangaPage.kt

@@ -1,10 +0,0 @@
-package io.legado.app.ui.book.manga.entities
-
-data class MangaPage(
-    override val chapterIndex: Int = 0,//总章节位置
-    val chapterSize: Int,//总章节数量
-    val mImageUrl: String = "",//当前URL
-    override val index: Int = 0,//当前章节位置
-    var imageCount: Int = 0,//当前章节内容总数
-    val mChapterName: String = "",//章节名称
-) : BaseMangaPage

+ 0 - 64
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/GestureDetectorWithLongTap.kt

@@ -1,64 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.content.Context
-import android.os.Handler
-import android.os.Looper
-import android.view.GestureDetector
-import android.view.MotionEvent
-import android.view.ViewConfiguration
-import kotlin.math.abs
-
-open class GestureDetectorWithLongTap(
-    context: Context,
-    listener: Listener,
-) : GestureDetector(context, listener) {
-
-    private val handler = Handler(Looper.getMainLooper())
-    private val slop = ViewConfiguration.get(context).scaledTouchSlop
-    private val longTapTime = ViewConfiguration.getLongPressTimeout().toLong()
-    private val doubleTapTime = ViewConfiguration.getDoubleTapTimeout().toLong()
-
-    private var downX = 0f
-    private var downY = 0f
-    private var lastUp = 0L
-    private var lastDownEvent: MotionEvent? = null
-
-
-    private val longTapFn = Runnable { listener.onLongTapConfirmed(lastDownEvent!!) }
-
-    override fun onTouchEvent(ev: MotionEvent): Boolean {
-        when (ev.actionMasked) {
-            MotionEvent.ACTION_DOWN -> {
-                lastDownEvent?.recycle()
-                lastDownEvent = MotionEvent.obtain(ev)
-
-                if (ev.downTime - lastUp > doubleTapTime) {
-                    downX = ev.rawX
-                    downY = ev.rawY
-                    handler.postDelayed(longTapFn, longTapTime)
-                }
-            }
-
-            MotionEvent.ACTION_MOVE -> {
-                if (abs(ev.rawX - downX) > slop || abs(ev.rawY - downY) > slop) {
-                    handler.removeCallbacks(longTapFn)
-                }
-            }
-
-            MotionEvent.ACTION_UP -> {
-                lastUp = ev.eventTime
-                handler.removeCallbacks(longTapFn)
-            }
-
-            MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_POINTER_DOWN -> {
-                handler.removeCallbacks(longTapFn)
-            }
-        }
-        return super.onTouchEvent(ev)
-    }
-
-    open class Listener : SimpleOnGestureListener() {
-        open fun onLongTapConfirmed(ev: MotionEvent) {
-        }
-    }
-}

+ 0 - 293
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaAdapter.kt

@@ -1,293 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.content.Context
-import android.graphics.ColorMatrixColorFilter
-import android.util.SparseArray
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import android.view.ViewGroup.LayoutParams.MATCH_PARENT
-import androidx.annotation.IntRange
-import androidx.core.util.size
-import androidx.core.view.updateLayoutParams
-import androidx.recyclerview.widget.AsyncListDiffer
-import androidx.recyclerview.widget.DiffUtil
-import androidx.recyclerview.widget.RecyclerView
-import androidx.viewbinding.ViewBinding
-import com.bumptech.glide.Glide
-import com.bumptech.glide.ListPreloader.PreloadModelProvider
-import com.bumptech.glide.RequestBuilder
-import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter.Companion.TYPE_FOOTER_VIEW
-import io.legado.app.databinding.ItemBookMangaEdgeBinding
-import io.legado.app.databinding.ItemBookMangaPageBinding
-import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.help.glide.progress.ProgressManager
-import io.legado.app.model.BookCover
-import io.legado.app.model.ReadManga
-import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
-import io.legado.app.ui.book.manga.entities.EpaperTransformation
-import io.legado.app.ui.book.manga.entities.GrayscaleTransformation
-import io.legado.app.ui.book.manga.entities.MangaPage
-import io.legado.app.ui.book.manga.entities.ReaderLoading
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
-import io.legado.app.utils.dpToPx
-
-
-class MangaAdapter(
-    private val context: Context,
-    private val settingsProvider: () -> MangaSettings,
-) :
-    RecyclerView.Adapter<RecyclerView.ViewHolder>(), PreloadModelProvider<Any> {
-
-    private val inflater: LayoutInflater = LayoutInflater.from(context)
-    private lateinit var mConfig: MangaColorFilterConfig
-    private var mTransformation: BitmapTransformation? = null
-    private var currentMangaEInkThreshold = 0
-
-    companion object {
-        private const val LOADING_VIEW = 0
-        private const val CONTENT_VIEW = 1
-    }
-
-    var isHorizontal = false
-    var enableDoublePageInLandscape = false
-
-
-    private val mDiffCallback: DiffUtil.ItemCallback<Any> = object : DiffUtil.ItemCallback<Any>() {
-        override fun areItemsTheSame(oldItem: Any, newItem: Any): Boolean {
-            return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
-                newItem.mMessage == oldItem.mMessage
-            } else if (oldItem is MangaPage && newItem is MangaPage) {
-                oldItem.mImageUrl == newItem.mImageUrl
-            } else false
-        }
-
-        override fun areContentsTheSame(oldItem: Any, newItem: Any): Boolean {
-            return if (oldItem is ReaderLoading && newItem is ReaderLoading) {
-                oldItem == newItem
-            } else if (oldItem is MangaPage && newItem is MangaPage) {
-                oldItem == newItem
-            } else false
-        }
-    }
-
-    private val mDiffer = AsyncListDiffer(this, mDiffCallback)
-
-    fun getItem(@IntRange(from = 0) position: Int) = mDiffer.currentList.getOrNull(position)
-
-    fun getItems() = mDiffer.currentList
-
-    fun isEmpty() = mDiffer.currentList.isEmpty()
-
-    fun isNotEmpty() = !isEmpty()
-
-    //全部替换数据
-    fun submitList(contents: List<Any>, runnable: Runnable? = null) {
-        mDiffer.submitList(contents, runnable)
-    }
-
-    inner class PageViewHolder(binding: ItemBookMangaPageBinding) :
-        MangaVH<ItemBookMangaPageBinding>(binding, context) {
-
-        init {
-            initComponent(
-                binding.loading,
-                binding.image,
-                binding.progress,
-                binding.retry,
-                binding.flProgress
-            )
-            binding.retry.setOnClickListener {
-                val item = mDiffer.currentList[layoutPosition]
-                if (item is MangaPage) {
-                    val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
-                    loadImageWithRetry(
-                        item.mImageUrl, isHorizontal, isLastImage, mTransformation
-                    )
-                }
-            }
-        }
-
-        fun onBind(item: MangaPage) {
-            setImageColorFilter()
-            setBackground()
-            val isLastImage = item.imageCount > 0 && item.index == item.imageCount - 1
-            loadImageWithRetry(item.mImageUrl, isHorizontal, isLastImage, mTransformation)
-        }
-
-        fun setImageColorFilter() {
-            require(
-                mConfig.r in 0..255 &&
-                        mConfig.g in 0..255 &&
-                        mConfig.b in 0..255 &&
-                        mConfig.a in 0..255
-            ) {
-                "ARGB values must be between 0-255"
-            }
-            val baseMatrix = floatArrayOf(
-                (255 - mConfig.r) / 255f, 0f, 0f, 0f, 0f,
-                0f, (255 - mConfig.g) / 255f, 0f, 0f, 0f,
-                0f, 0f, (255 - mConfig.b) / 255f, 0f, 0f,
-                0f, 0f, 0f, (255 - mConfig.a) / 255f, 0f
-            )
-
-            binding.image.colorFilter = ColorMatrixColorFilter(baseMatrix)
-        }
-
-        fun setBackground() {
-            val background = settingsProvider().background
-            binding.rootView.setBackgroundColor(background)
-            binding.flProgress.setBackgroundColor(background)
-        }
-    }
-
-    inner class PageMoreViewHolder(val binding: ItemBookMangaEdgeBinding) :
-        RecyclerView.ViewHolder(binding.root) {
-        fun onBind(item: ReaderLoading) {
-            val message = item.mMessage ?: ""
-            val parts = message.split(" ", limit = 2)
-            if (parts.size == 2) {
-                binding.text.text = parts[0]
-                binding.textChapter.text = parts[1]
-            } else {
-                binding.text.text = message
-                binding.textChapter.text = ""
-            }
-            itemView.updateLayoutParams {
-                height = if (isHorizontal) {
-                    MATCH_PARENT
-                } else {
-                    96.dpToPx()
-                }
-            }
-        }
-    }
-
-    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
-        return when {
-            viewType >= TYPE_FOOTER_VIEW -> {
-                ItemViewHolder(footerItems.get(viewType).invoke(parent))
-            }
-
-            viewType == LOADING_VIEW -> {
-                PageMoreViewHolder(ItemBookMangaEdgeBinding.inflate(inflater, parent, false))
-            }
-
-            viewType == CONTENT_VIEW -> {
-                PageViewHolder(ItemBookMangaPageBinding.inflate(inflater, parent, false))
-            }
-
-            else -> error("Unknown view type!")
-        }
-    }
-
-    override fun getItemCount(): Int = getActualItemCount() + getFooterCount()
-
-    override fun getItemViewType(position: Int): Int {
-        return when {
-            isFooter(position) -> TYPE_FOOTER_VIEW + position - getActualItemCount()
-            getItem(position) is MangaPage -> CONTENT_VIEW
-            getItem(position) is ReaderLoading -> LOADING_VIEW
-            else -> error("Unknown view type!")
-        }
-    }
-
-    fun getFooterCount() = footerItems.size
-
-    private fun isFooter(position: Int) = position >= getActualItemCount()
-
-    override fun onViewRecycled(vh: RecyclerView.ViewHolder) {
-        super.onViewRecycled(vh)
-        when (vh) {
-            is PageViewHolder -> {
-                vh.itemView.updateLayoutParams<ViewGroup.LayoutParams> {
-                    height = MATCH_PARENT
-                }
-                Glide.with(context).clear(vh.binding.image)
-                if (vh.binding.image.tag is String) {
-                    ProgressManager.removeListener(vh.binding.image.tag as String)
-                }
-            }
-        }
-    }
-
-    override fun onBindViewHolder(vh: RecyclerView.ViewHolder, position: Int) {
-        when (vh) {
-            is PageViewHolder -> vh.onBind(getItem(position) as MangaPage)
-            is PageMoreViewHolder -> vh.onBind(getItem(position) as ReaderLoading)
-        }
-    }
-
-
-    private val footerItems: SparseArray<(parent: ViewGroup) -> ViewBinding> by lazy { SparseArray() }
-
-    @Synchronized
-    fun addFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
-        kotlin.runCatching {
-            val index = getActualItemCount() + footerItems.size
-            footerItems.put(TYPE_FOOTER_VIEW + footerItems.size, footer)
-            notifyItemInserted(index)
-        }
-    }
-
-    /**
-     * 除去header和footer
-     */
-    fun getActualItemCount() = getItems().size
-
-    @Synchronized
-    fun removeFooterView(footer: ((parent: ViewGroup) -> ViewBinding)) {
-        kotlin.runCatching {
-            val index = footerItems.indexOfValue(footer)
-            if (index >= 0) {
-                footerItems.remove(index)
-                notifyItemRemoved(getActualItemCount() + index - 2)
-            }
-        }
-    }
-
-    override fun getPreloadItems(position: Int): List<Any> {
-        if (isEmpty() || position >= getItems().size) {
-            return emptyList()
-        }
-        return getItems().subList(position, position + 1)
-    }
-
-    override fun getPreloadRequestBuilder(item: Any): RequestBuilder<*>? {
-        if (item is MangaPage) {
-            return BookCover.preloadManga(
-                context,
-                item.mImageUrl,
-                sourceOrigin = ReadManga.book?.origin,
-            )
-        }
-        return null
-    }
-
-    fun setMangaImageColorFilter(config: MangaColorFilterConfig) {
-        mConfig = config
-        notifyItemRangeChanged(0, itemCount)
-    }
-
-    fun enableMangaEInk(enable: Boolean, value: Int) {
-        if (enable) {
-            currentMangaEInkThreshold = value
-            mTransformation = EpaperTransformation(value)
-        } else {
-            mTransformation = null
-        }
-        notifyItemRangeChanged(0, itemCount)
-    }
-
-    //开启灰色图片
-    fun enableGray(enable: Boolean) {
-        mTransformation = if (enable) {
-            GrayscaleTransformation()
-        } else {
-            null
-        }
-        notifyItemRangeChanged(0, itemCount)
-    }
-
-}

+ 0 - 17
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaLayoutManager.kt

@@ -1,17 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.content.Context
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-
-class MangaLayoutManager(context: Context) :
-    LinearLayoutManager(context) {
-
-    private val extraLayoutSpace = context.resources.displayMetrics.heightPixels * 3 / 4
-
-    @Deprecated("Deprecated in Java")
-    override fun getExtraLayoutSpace(state: RecyclerView.State?): Int {
-        return extraLayoutSpace
-    }
-
-}

+ 0 - 141
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaVH.kt

@@ -1,141 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.graphics.Bitmap
-import android.graphics.drawable.Drawable
-import android.view.Gravity
-import android.view.ViewGroup
-import android.widget.Button
-import android.widget.FrameLayout
-import android.widget.ImageView
-import android.widget.ProgressBar
-import android.widget.TextView
-import androidx.appcompat.widget.AppCompatImageView
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
-import androidx.core.view.updateLayoutParams
-import androidx.recyclerview.widget.RecyclerView
-import androidx.viewbinding.ViewBinding
-import com.bumptech.glide.load.DataSource
-import com.bumptech.glide.load.Transformation
-import com.bumptech.glide.load.engine.GlideException
-import com.bumptech.glide.request.RequestListener
-import com.bumptech.glide.request.target.Target
-import io.legado.app.help.glide.progress.ProgressManager
-import io.legado.app.model.BookCover
-import io.legado.app.model.ReadManga
-import io.legado.app.utils.printOnDebug
-
-open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Context) :
-    RecyclerView.ViewHolder(binding.root) {
-
-    protected lateinit var mLoading: ProgressBar
-    protected lateinit var mImage: AppCompatImageView
-    protected lateinit var mProgress: TextView
-    protected lateinit var mFlProgress: FrameLayout
-    protected var mRetry: Button? = null
-
-    private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
-
-    fun initComponent(
-        loading: ProgressBar,
-        image: AppCompatImageView,
-        progress: TextView,
-        button: Button? = null,
-        flProgress: FrameLayout,
-    ) {
-        mLoading = loading
-        mImage = image
-        mRetry = button
-        mProgress = progress
-        mFlProgress = flProgress
-    }
-
-    @SuppressLint("CheckResult")
-    fun loadImageWithRetry(
-        imageUrl: String,
-        isHorizontal: Boolean,
-        isLastImage: Boolean,
-        transformation: Transformation<Bitmap>?
-    ) {
-        mFlProgress.isVisible = true
-        mLoading.isVisible = true
-        mRetry?.isGone = true
-        mProgress.isVisible = true
-        ProgressManager.removeListener(imageUrl)
-        ProgressManager.addListener(imageUrl) { _, percentage, _, _ ->
-            @SuppressLint("SetTextI18n")
-            mProgress.text = "$percentage%"
-        }
-        try {
-            mImage.tag = imageUrl
-            BookCover.loadManga(
-                context,
-                imageUrl,
-                sourceOrigin = ReadManga.book?.origin,
-                transformation = transformation
-            ).addListener(object : RequestListener<Drawable> {
-                override fun onLoadFailed(
-                    e: GlideException?,
-                    model: Any?,
-                    target: Target<Drawable>,
-                    isFirstResource: Boolean,
-                ): Boolean {
-                    mFlProgress.isVisible = true
-                    mLoading.isGone = true
-                    mRetry?.isVisible = true
-                    mProgress.isGone = true
-                    itemView.updateLayoutParams<ViewGroup.LayoutParams> {
-                        height = ViewGroup.LayoutParams.MATCH_PARENT
-                    }
-                    return false
-                }
-
-                override fun onResourceReady(
-                    resource: Drawable,
-                    model: Any,
-                    target: Target<Drawable>?,
-                    dataSource: DataSource,
-                    isFirstResource: Boolean,
-                ): Boolean {
-                    mFlProgress.isGone = true
-                    if (!isHorizontal) {
-                        itemView.updateLayoutParams<ViewGroup.LayoutParams> {
-                            height = ViewGroup.LayoutParams.WRAP_CONTENT
-                        }
-                        mImage.updateLayoutParams<FrameLayout.LayoutParams> {
-                            gravity = Gravity.NO_GRAVITY
-                        }
-                        if (isLastImage) {
-                            mImage.updateLayoutParams<FrameLayout.LayoutParams> {
-                                height = ViewGroup.LayoutParams.WRAP_CONTENT
-                            }
-                            itemView.minimumHeight = minHeight
-                        } else {
-                            mImage.updateLayoutParams<FrameLayout.LayoutParams> {
-                                height = ViewGroup.LayoutParams.MATCH_PARENT
-                            }
-                            itemView.minimumHeight = 0
-                        }
-                        mImage.scaleType = ImageView.ScaleType.FIT_XY
-                    } else {
-                        itemView.updateLayoutParams<ViewGroup.LayoutParams> {
-                            height = ViewGroup.LayoutParams.MATCH_PARENT
-                        }
-                        itemView.minimumHeight = 0
-                        mImage.updateLayoutParams<FrameLayout.LayoutParams> {
-                            height = ViewGroup.LayoutParams.MATCH_PARENT
-                            gravity = Gravity.CENTER
-                        }
-                        mImage.scaleType = ImageView.ScaleType.FIT_CENTER
-                    }
-                    return false
-                }
-            }).into(mImage)
-        } catch (e: Exception) {
-            e.printOnDebug()
-        }
-
-    }
-}

+ 0 - 76
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/ScrollTimer.kt

@@ -1,76 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import androidx.recyclerview.widget.RecyclerView
-import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-
-class ScrollTimer(
-    private val callback: ScrollCallback,
-    private val recyclerView: RecyclerView,
-    private val coroutineScope: CoroutineScope,
-) : RecyclerView.OnScrollListener() {
-    private var distance = 1
-    private var mScrollPageJob: Job? = null
-
-    var isEnabled: Boolean = false
-        set(value) {
-            if (field != value) {
-                field = value
-                if (value) {
-                    recyclerView.addOnScrollListener(this)
-                    startScroll()
-                } else {
-                    recyclerView.removeOnScrollListener(this)
-                    recyclerView.stopScroll()
-                }
-            }
-        }
-
-    var isEnabledPage: Boolean = false
-        set(value) {
-            if (field != value) {
-                field = value
-                if (value) {
-                    mScrollPageJob?.cancel()
-                    startScrollPage()
-                } else {
-                    mScrollPageJob?.cancel()
-                }
-            }
-        }
-
-    override fun onScrollStateChanged(
-        recyclerView: RecyclerView,
-        newState: Int,
-    ) {
-        if (newState == SCROLL_STATE_IDLE) {
-            startScroll()
-        }
-    }
-
-    fun setSpeed(distance: Int) {
-        this.distance = distance
-    }
-
-    private fun startScroll() {
-        callback.scrollBy(distance)
-    }
-
-    private fun startScrollPage() {
-        mScrollPageJob = coroutineScope.launch {
-            while (isActive) {
-                delay(distance.times(1000L))
-                callback.scrollPage(1)
-            }
-        }
-    }
-
-    interface ScrollCallback {
-        fun scrollBy(distance: Int)
-        fun scrollPage(direction: Int)
-    }
-}

+ 0 - 221
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonFrame.kt

@@ -1,221 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.content.Context
-import android.graphics.Rect
-import android.graphics.RectF
-import android.util.AttributeSet
-import android.view.GestureDetector
-import android.view.MotionEvent
-import android.view.ScaleGestureDetector
-import android.view.View
-import android.widget.FrameLayout
-import androidx.core.view.ViewCompat
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.R
-import io.legado.app.domain.model.settings.MangaSettings
-import io.legado.app.utils.findCenterViewPosition
-
-class WebtoonFrame : FrameLayout {
-
-    constructor(context: Context) : super(context)
-    constructor(context: Context, attrs: AttributeSet) : super(context, attrs)
-    constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super(
-        context,
-        attrs,
-        defStyle
-    )
-
-    init {
-        contentDescription = context.getString(R.string.manga)
-        isFocusable = true
-        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
-
-        ViewCompat.replaceAccessibilityAction(
-            this,
-            AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
-            context.getString(R.string.menu),
-        ) { _, _ -> performClick() }
-        addAccessibilityAction(R.string.prev_page) { it.prevPage() }
-        addAccessibilityAction(R.string.next_page) { it.nextPage() }
-        addAccessibilityAction(R.string.a11y_previous_chapter) { it.prevChapter() }
-        addAccessibilityAction(R.string.a11y_next_chapter) { it.nextChapter() }
-    }
-
-    /** 长按回调,返回手指坐标 */
-    var longPressListener: ((centerPosition: Int) -> Unit)? = null
-
-    private val scaleDetector = ScaleGestureDetector(context, ScaleListener())
-    private val flingDetector = GestureDetector(context, FlingListener())
-    private val regionRects = mutableMapOf<String, RectF>()
-
-    var doubleTapZoom = true
-        set(value) {
-            field = value
-            recycler?.doubleTapZoom = value
-            scaleDetector.isQuickScaleEnabled = value
-        }
-
-    var disableMangaScale = false
-
-    private val recycler: WebtoonRecyclerView?
-        get() = getChildAt(0) as? WebtoonRecyclerView
-
-    var actionHandler: ClickActionHandler? = null
-    var settingsProvider: () -> MangaSettings = { MangaSettings() }
-
-    private val gestureDetector =
-        GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
-            override fun onLongPress(e: MotionEvent) {
-                if (settingsProvider().longClick)
-                {
-                    recycler?.let {
-                        val centerPos = it.findCenterViewPosition()
-                        if (centerPos != RecyclerView.NO_POSITION) {
-                            longPressListener?.invoke(centerPos)
-                        }
-                    }
-                }
-            }
-
-            override fun onDown(e: MotionEvent): Boolean = true
-        })
-
-    override fun onAttachedToWindow() {
-        super.onAttachedToWindow()
-        recycler?.tapListener = tapListener@{ ev ->
-            val x = ev.rawX
-            val y = ev.rawY
-            val key =
-                regionRects.entries.find { it.value.contains(x, y) }?.key ?: return@tapListener
-
-            val settings = settingsProvider()
-            val action = when (key) {
-                "TL" -> settings.clickActionTL
-                "TC" -> settings.clickActionTC
-                "TR" -> settings.clickActionTR
-                "ML" -> settings.clickActionML
-                "MC" -> settings.clickActionMC
-                "MR" -> settings.clickActionMR
-                "BL" -> settings.clickActionBL
-                "BC" -> settings.clickActionBC
-                "BR" -> settings.clickActionBR
-                else -> -1
-            }
-
-            performClickAction(action)
-        }
-    }
-
-    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
-        super.onSizeChanged(w, h, oldw, oldh)
-        val cellWidth = w / 3f
-        val cellHeight = h / 3f
-        val keys = listOf("TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR")
-
-        keys.forEachIndexed { index, key ->
-            val row = index / 3
-            val col = index % 3
-            regionRects[key] = RectF(
-                col * cellWidth,
-                row * cellHeight,
-                (col + 1) * cellWidth,
-                (row + 1) * cellHeight
-            )
-        }
-    }
-
-    override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
-        gestureDetector.onTouchEvent(ev)  // 捕获长按
-
-        if (!disableMangaScale) {
-            scaleDetector.onTouchEvent(ev)
-            flingDetector.onTouchEvent(ev)
-            val recyclerRect = Rect()
-            recycler?.getHitRect(recyclerRect) ?: return super.dispatchTouchEvent(ev)
-            recyclerRect.inset(1, 1)
-
-            if (recyclerRect.right < recyclerRect.left || recyclerRect.bottom < recyclerRect.top) {
-                return super.dispatchTouchEvent(ev)
-            }
-
-            ev.setLocation(
-                ev.x.coerceIn(recyclerRect.left.toFloat(), recyclerRect.right.toFloat()),
-                ev.y.coerceIn(recyclerRect.top.toFloat(), recyclerRect.bottom.toFloat())
-            )
-        }
-        return super.dispatchTouchEvent(ev)
-    }
-
-    override fun performClick(): Boolean {
-        val handler = actionHandler ?: return super.performClick()
-        super.performClick()
-        handler.showMenu()
-        return true
-    }
-
-    fun updateAccessibilityState(description: String) {
-        ViewCompat.setStateDescription(this, description)
-    }
-
-    private fun addAccessibilityAction(
-        labelRes: Int,
-        action: (ClickActionHandler) -> Unit,
-    ) {
-        ViewCompat.addAccessibilityAction(this, context.getString(labelRes)) { _, _ ->
-            actionHandler?.let {
-                action(it)
-                true
-            } ?: false
-        }
-    }
-
-    private fun performClickAction(action: Int) {
-        when (action) {
-            -1 -> return
-            0 -> actionHandler?.showMenu()
-            1 -> actionHandler?.nextPage()
-            2 -> actionHandler?.prevPage()
-            3 -> actionHandler?.nextChapter()
-            4 -> actionHandler?.prevChapter()
-            else -> {}
-        }
-    }
-
-    inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
-        override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
-            recycler?.onScaleBegin()
-            return true
-        }
-
-        override fun onScale(detector: ScaleGestureDetector): Boolean {
-            recycler?.onScale(detector.scaleFactor)
-            return true
-        }
-
-        override fun onScaleEnd(detector: ScaleGestureDetector) {
-            recycler?.onScaleEnd()
-        }
-    }
-
-    inner class FlingListener : GestureDetector.SimpleOnGestureListener() {
-        override fun onDown(e: MotionEvent): Boolean = true
-
-        override fun onFling(
-            e1: MotionEvent?,
-            e2: MotionEvent,
-            velocityX: Float,
-            velocityY: Float
-        ): Boolean {
-            return recycler?.zoomFling(velocityX.toInt(), velocityY.toInt()) ?: false
-        }
-    }
-
-    interface ClickActionHandler {
-        fun showMenu()
-        fun nextPage()
-        fun prevPage()
-        fun nextChapter()
-        fun prevChapter()
-    }
-}

+ 0 - 379
app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonRecyclerView.kt

@@ -1,379 +0,0 @@
-package io.legado.app.ui.book.manga.recyclerview
-
-import android.animation.AnimatorSet
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.Context
-import android.util.AttributeSet
-import android.view.MotionEvent
-import android.view.ViewConfiguration
-import android.view.animation.DecelerateInterpolator
-import androidx.core.animation.doOnEnd
-import androidx.core.view.HapticFeedbackConstantsCompat
-import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.recyclerview.widget.RecyclerView
-import io.legado.app.utils.findCenterViewPosition
-import kotlin.math.abs
-
-
-class WebtoonRecyclerView @JvmOverloads constructor(
-    context: Context,
-    attrs: AttributeSet? = null,
-    defStyle: Int = 0,
-) : RecyclerView(context, attrs, defStyle) {
-
-    private var isZooming = false
-    private var atLastPosition = false
-    private var atFirstPosition = false
-    private var halfWidth = 0
-    private var halfHeight = 0
-    private var originalHeight = 0
-    private var heightSet = false
-    private var firstVisibleItemPosition = 0
-    private var lastVisibleItemPosition = 0
-    private var currentScale = DEFAULT_RATE
-    private var mLastCenterViewPosition = 0
-
-    private var mPreScrollListener: IComicPreScroll? = null
-    private var mNestedPreScrollListener: IComicPreScroll? = null
-    private val listener = GestureListener()
-    private val detector = Detector()
-
-    var doubleTapZoom = true
-    var tapListener: ((MotionEvent) -> Unit)? = null
-    var longTapListener: ((MotionEvent) -> Boolean)? = null
-    var disableMangaScale = false
-
-    override fun onMeasure(widthSpec: Int, heightSpec: Int) {
-        halfWidth = MeasureSpec.getSize(widthSpec) / 2
-        halfHeight = MeasureSpec.getSize(heightSpec) / 2
-        if (!heightSet) {
-            originalHeight = MeasureSpec.getSize(heightSpec)
-            heightSet = true
-        }
-        super.onMeasure(widthSpec, heightSpec)
-    }
-
-    @SuppressLint("ClickableViewAccessibility")
-    override fun onTouchEvent(e: MotionEvent): Boolean {
-        return detector.onTouchEvent(e) || super.onTouchEvent(e)
-    }
-
-    override fun onScrolled(dx: Int, dy: Int) {
-        super.onScrolled(dx, dy)
-        val layoutManager = layoutManager
-        lastVisibleItemPosition =
-            (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
-        firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
-        if (dx != 0 || dy != 0) {
-            notifyCenterPositionChanged(dx, dy)
-        }
-    }
-
-    override fun onScrollStateChanged(state: Int) {
-        super.onScrollStateChanged(state)
-        val layoutManager = layoutManager
-        val visibleItemCount = layoutManager?.childCount ?: 0
-        val totalItemCount = layoutManager?.itemCount ?: 0
-        atLastPosition = visibleItemCount > 0 && lastVisibleItemPosition == totalItemCount - 1
-        atFirstPosition = firstVisibleItemPosition == 0
-    }
-
-    override fun dispatchNestedPreScroll(
-        dx: Int,
-        dy: Int,
-        consumed: IntArray?,
-        offsetInWindow: IntArray?,
-        type: Int
-    ): Boolean {
-        val position = findCenterViewPosition()
-        mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
-        return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
-    }
-
-    private fun notifyCenterPositionChanged(dx: Int, dy: Int) {
-        val position = findCenterViewPosition()
-        if (position != NO_POSITION && position != mLastCenterViewPosition) {
-            mLastCenterViewPosition = position
-            mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
-        }
-    }
-
-    private fun getPositionX(positionX: Float): Float {
-        if (currentScale < 1) {
-            return 0f
-        }
-        val maxPositionX = halfWidth * (currentScale - 1)
-        return positionX.coerceIn(-maxPositionX, maxPositionX)
-    }
-
-    private fun getPositionY(positionY: Float): Float {
-        if (currentScale < 1) {
-            return (originalHeight / 2 - halfHeight).toFloat()
-        }
-        val maxPositionY = halfHeight * (currentScale - 1)
-        return positionY.coerceIn(-maxPositionY, maxPositionY)
-    }
-
-    private fun zoom(
-        fromRate: Float,
-        toRate: Float,
-        fromX: Float,
-        toX: Float,
-        fromY: Float,
-        toY: Float,
-    ) {
-        isZooming = true
-        val animatorSet = AnimatorSet()
-        val translationXAnimator = ValueAnimator.ofFloat(fromX, toX)
-        translationXAnimator.addUpdateListener { animation -> x = animation.animatedValue as Float }
-
-        val translationYAnimator = ValueAnimator.ofFloat(fromY, toY)
-        translationYAnimator.addUpdateListener { animation -> y = animation.animatedValue as Float }
-
-        val scaleAnimator = ValueAnimator.ofFloat(fromRate, toRate)
-        scaleAnimator.addUpdateListener { animation ->
-            currentScale = animation.animatedValue as Float
-            setScaleRate(currentScale)
-        }
-        animatorSet.playTogether(translationXAnimator, translationYAnimator, scaleAnimator)
-        animatorSet.duration = ANIMATOR_DURATION_TIME.toLong()
-        animatorSet.interpolator = DecelerateInterpolator()
-        animatorSet.start()
-        animatorSet.doOnEnd {
-            isZooming = false
-            currentScale = toRate
-        }
-    }
-
-    fun zoomFling(velocityX: Int, velocityY: Int): Boolean {
-        if (currentScale <= 1f) return false
-
-        val distanceTimeFactor = 0.4f
-        val animatorSet = AnimatorSet()
-
-        if (velocityX != 0) {
-            val dx = (distanceTimeFactor * velocityX / 2)
-            val newX = getPositionX(x + dx)
-            val translationXAnimator = ValueAnimator.ofFloat(x, newX)
-            translationXAnimator.addUpdateListener { animation ->
-                x = getPositionX(animation.animatedValue as Float)
-            }
-            animatorSet.play(translationXAnimator)
-        }
-        if (velocityY != 0 && (atFirstPosition || atLastPosition)) {
-            val dy = (distanceTimeFactor * velocityY / 2)
-            val newY = getPositionY(y + dy)
-            val translationYAnimator = ValueAnimator.ofFloat(y, newY)
-            translationYAnimator.addUpdateListener { animation ->
-                y = getPositionY(animation.animatedValue as Float)
-            }
-            animatorSet.play(translationYAnimator)
-        }
-
-        animatorSet.duration = 400
-        animatorSet.interpolator = DecelerateInterpolator()
-        animatorSet.start()
-
-        return true
-    }
-
-    fun resetZoom() {
-        zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
-    }
-
-    private fun zoomScrollBy(dx: Int, dy: Int) {
-        if (dx != 0) {
-            x = getPositionX(x + dx)
-        }
-        if (dy != 0) {
-            y = getPositionY(y + dy)
-        }
-    }
-
-    private fun setScaleRate(rate: Float) {
-        scaleX = rate
-        scaleY = rate
-    }
-
-    fun onScale(scaleFactor: Float) {
-        currentScale *= scaleFactor
-        currentScale = currentScale.coerceIn(
-            MIN_RATE,
-            MAX_SCALE_RATE,
-        )
-
-        setScaleRate(currentScale)
-
-        layoutParams.height = if (currentScale < 1) {
-            (originalHeight / currentScale).toInt()
-        } else {
-            originalHeight
-        }
-        halfHeight = layoutParams.height / 2
-
-        if (currentScale != DEFAULT_RATE) {
-            x = getPositionX(x)
-            y = getPositionY(y)
-        } else {
-            x = 0f
-            y = 0f
-        }
-
-        requestLayout()
-    }
-
-    fun onScaleBegin() {
-        if (detector.isDoubleTapping) {
-            detector.isQuickScaling = true
-        }
-    }
-
-    fun onScaleEnd() {
-        if (scaleX < MIN_RATE) {
-            zoom(currentScale, MIN_RATE, x, 0f, y, 0f)
-        }
-    }
-
-    inner class GestureListener : GestureDetectorWithLongTap.Listener() {
-
-        override fun onSingleTapConfirmed(ev: MotionEvent): Boolean {
-            tapListener?.invoke(ev)
-            return false
-        }
-
-        override fun onDoubleTap(ev: MotionEvent): Boolean {
-            detector.isDoubleTapping = true
-            return false
-        }
-
-        fun onDoubleTapConfirmed(ev: MotionEvent) {
-            if (!isZooming && doubleTapZoom) {
-                if (scaleX != DEFAULT_RATE) {
-                    zoom(currentScale, DEFAULT_RATE, x, 0f, y, 0f)
-                } else {
-                    val toScale = 2f
-                    val toX = (halfWidth - ev.x) * (toScale - 1)
-                    val toY = (halfHeight - ev.y) * (toScale - 1)
-                    zoom(DEFAULT_RATE, toScale, 0f, toX, 0f, toY)
-                }
-            }
-        }
-
-        override fun onLongTapConfirmed(ev: MotionEvent) {
-            if (longTapListener?.invoke(ev) == true) {
-                performHapticFeedback(HapticFeedbackConstantsCompat.LONG_PRESS)
-            }
-        }
-    }
-
-    inner class Detector : GestureDetectorWithLongTap(context, listener) {
-
-        private var scrollPointerId = 0
-        private var downX = 0
-        private var downY = 0
-        private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop
-        private var isZoomDragging = false
-        var isDoubleTapping = false
-        var isQuickScaling = false
-        override fun onTouchEvent(ev: MotionEvent): Boolean {
-            val action = ev.actionMasked
-            val actionIndex = ev.actionIndex
-
-            when (action) {
-                MotionEvent.ACTION_DOWN -> {
-                    scrollPointerId = ev.getPointerId(0)
-                    downX = (ev.x + 0.5f).toInt()
-                    downY = (ev.y + 0.5f).toInt()
-                }
-
-                MotionEvent.ACTION_POINTER_DOWN -> {
-                    scrollPointerId = ev.getPointerId(actionIndex)
-                    downX = (ev.getX(actionIndex) + 0.5f).toInt()
-                    downY = (ev.getY(actionIndex) + 0.5f).toInt()
-                }
-
-                MotionEvent.ACTION_MOVE -> {
-                    if (disableMangaScale) {
-                        return super.onTouchEvent(ev)
-                    }
-                    if (isDoubleTapping && isQuickScaling) {
-                        return true
-                    }
-
-                    val index = ev.findPointerIndex(scrollPointerId)
-                    if (index < 0) {
-                        return false
-                    }
-
-                    val x = (ev.getX(index) + 0.5f).toInt()
-                    val y = (ev.getY(index) + 0.5f).toInt()
-                    var dx = x - downX
-                    var dy = if (atFirstPosition || atLastPosition) y - downY else 0
-
-                    if (!isZoomDragging && currentScale > 1f) {
-                        var startScroll = false
-
-                        if (abs(dx) > touchSlop) {
-                            if (dx < 0) {
-                                dx += touchSlop
-                            } else {
-                                dx -= touchSlop
-                            }
-                            startScroll = true
-                        }
-                        if (abs(dy) > touchSlop) {
-                            if (dy < 0) {
-                                dy += touchSlop
-                            } else {
-                                dy -= touchSlop
-                            }
-                            startScroll = true
-                        }
-
-                        if (startScroll) {
-                            isZoomDragging = true
-                        }
-                    }
-
-                    if (isZoomDragging) {
-                        zoomScrollBy(dx, dy)
-                    }
-                }
-
-                MotionEvent.ACTION_UP -> {
-                    if (isDoubleTapping && !isQuickScaling && !disableMangaScale) {
-                        listener.onDoubleTapConfirmed(ev)
-                    }
-                    isZoomDragging = false
-                    isDoubleTapping = false
-                    isQuickScaling = false
-                }
-
-                MotionEvent.ACTION_CANCEL -> {
-                    isZoomDragging = false
-                    isDoubleTapping = false
-                    isQuickScaling = false
-                }
-            }
-            return super.onTouchEvent(ev)
-        }
-    }
-
-    fun setPreScrollListener(iComicPreScroll: IComicPreScroll) {
-        mPreScrollListener = iComicPreScroll
-    }
-
-    fun setNestedPreScrollListener(iComicPreScroll: IComicPreScroll) {
-        mNestedPreScrollListener = iComicPreScroll
-    }
-
-    fun interface IComicPreScroll {
-        fun onPreScrollListener(recyclerView: RecyclerView, dx: Int, dy: Int, position: Int)
-    }
-}
-
-private const val ANIMATOR_DURATION_TIME = 200
-private const val MIN_RATE = 0.5f
-private const val DEFAULT_RATE = 1f
-private const val MAX_SCALE_RATE = 3f

+ 0 - 342
app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt

@@ -1,342 +0,0 @@
-package io.legado.app.ui.book.read
-
-//import io.legado.app.lib.theme.bottomBackground
-import android.annotation.SuppressLint
-import android.content.Context
-import android.util.AttributeSet
-import android.view.LayoutInflater
-import android.view.animation.Animation
-import android.widget.FrameLayout
-import androidx.appcompat.widget.PopupMenu
-import androidx.core.view.ViewCompat
-import androidx.core.view.HapticFeedbackConstantsCompat
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
-import com.google.android.material.slider.Slider
-import io.legado.app.R
-import io.legado.app.databinding.ViewMangaMenuBinding
-import io.legado.app.help.source.getSourceType
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.model.ReadBook
-import io.legado.app.model.ReadManga
-import io.legado.app.ui.browser.WebViewActivity
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.domain.gateway.ReadSettingsGateway
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.launch
-import org.koin.core.context.GlobalContext
-import io.legado.app.utils.activity
-import io.legado.app.utils.applyNavigationBarPadding
-import io.legado.app.utils.gone
-import io.legado.app.utils.invisible
-import io.legado.app.utils.loadAnimation
-import io.legado.app.utils.openUrl
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.visible
-
-class MangaMenu @JvmOverloads constructor(
-    context: Context,
-    attrs: AttributeSet? = null,
-) : FrameLayout(context, attrs) {
-    private val binding = ViewMangaMenuBinding.inflate(LayoutInflater.from(context), this, true)
-    private val callBack: CallBack get() = activity as CallBack
-    private val readSettingsGateway by lazy {
-        GlobalContext.get().get<ReadSettingsGateway>()
-    }
-    var canShowMenu: Boolean = false
-
-    private val sourceMenu by lazy {
-        PopupMenu(context, binding.tvSourceAction).apply {
-            inflate(R.menu.book_read_source)
-            setOnMenuItemClickListener {
-                when (it.itemId) {
-                    R.id.menu_login -> callBack.showLogin()
-                    R.id.menu_chapter_pay -> callBack.payAction()
-                    R.id.menu_edit_source -> callBack.openSourceEditActivity()
-                    R.id.menu_disable_source -> callBack.disableSource()
-                }
-                true
-            }
-        }
-    }
-
-    private val menuTopIn: Animation by lazy {
-        loadAnimation(context, R.anim.anim_readbook_top_in)
-    }
-    private val menuTopOut: Animation by lazy {
-        loadAnimation(context, R.anim.anim_readbook_top_out)
-    }
-    private val menuBottomIn: Animation by lazy {
-        loadAnimation(context, R.anim.anim_readbook_bottom_in)
-    }
-    private val menuBottomOut: Animation by lazy {
-        loadAnimation(context, R.anim.anim_readbook_bottom_out)
-    }
-    private var isMenuOutAnimating = false
-    //private var bgColor = context.bottomBackground
-
-    private val menuOutListener = object : Animation.AnimationListener {
-        override fun onAnimationStart(animation: Animation) {
-            isMenuOutAnimating = true
-            binding.vwMenuBg.setOnClickListener(null)
-        }
-
-        override fun onAnimationEnd(animation: Animation) {
-            this@MangaMenu.invisible()
-            binding.titleBar.invisible()
-            binding.bottomMenu.invisible()
-            isMenuOutAnimating = false
-            canShowMenu = false
-            callBack.upSystemUiVisibility(false)
-        }
-
-        override fun onAnimationRepeat(animation: Animation) = Unit
-    }
-    private val menuInListener = object : Animation.AnimationListener {
-        override fun onAnimationStart(animation: Animation) {
-            binding.tvSourceAction.text =
-                ReadManga.bookSource?.bookSourceName ?: context.getString(R.string.book_source)
-            updateSourceActionDescription()
-            callBack.upSystemUiVisibility(true)
-            binding.tvSourceAction.isGone = false
-        }
-
-        @SuppressLint("RtlHardcoded")
-        override fun onAnimationEnd(animation: Animation) {
-            binding.run {
-                vwMenuBg.setOnClickListener { runMenuOut() }
-            }
-        }
-
-        override fun onAnimationRepeat(animation: Animation) = Unit
-    }
-
-    init {
-        initView()
-        bindEvent()
-    }
-
-    private fun initView() = binding.run {
-        initAnimation()
-//        val brightnessBackground = GradientDrawable()
-//        brightnessBackground.cornerRadius = 5F.dpToPx()
-//        //brightnessBackground.setColor(ColorUtils.adjustAlpha(bgColor, 0.5f))
-//        if (ReadConfig.isEInkMode) {
-//            titleBar.setBackgroundResource(R.drawable.bg_eink_border_bottom)
-//            bottomMenu.setBackgroundResource(R.drawable.bg_eink_border_top)
-//        } else {
-//            //bottomMenu.setBackgroundColor(bgColor)
-//        }
-        if (ReadConfig.showReadTitleAddition) {
-            titleBarAddition.visible()
-        } else {
-            titleBarAddition.gone()
-        }
-        /**
-         * 确保视图不被导航栏遮挡
-         */
-        bottomView.applyNavigationBarPadding()
-    }
-
-    private fun initAnimation() {
-        menuTopIn.setAnimationListener(menuInListener)
-        menuTopOut.setAnimationListener(menuOutListener)
-    }
-
-    fun runMenuOut(anim: Boolean = !ReadConfig.isEInkMode) {
-        if (isMenuOutAnimating) {
-            return
-        }
-        if (this.isVisible) {
-            if (anim) {
-                binding.titleBar.startAnimation(menuTopOut)
-                binding.bottomMenu.startAnimation(menuBottomOut)
-            } else {
-                menuOutListener.onAnimationStart(menuBottomOut)
-                menuOutListener.onAnimationEnd(menuBottomOut)
-            }
-        }
-    }
-
-    fun runMenuIn(anim: Boolean = !ReadConfig.isEInkMode) {
-        this.visible()
-        binding.titleBar.visible()
-        binding.bottomMenu.visible()
-        if (anim) {
-            binding.titleBar.startAnimation(menuTopIn)
-            binding.bottomMenu.startAnimation(menuBottomIn)
-        } else {
-            menuInListener.onAnimationStart(menuBottomIn)
-            menuInListener.onAnimationEnd(menuBottomIn)
-        }
-    }
-
-    fun upBookView() {
-        binding.titleBar.title = " "
-        binding.tvBookName.text = ReadManga.book?.name
-        updateSourceActionDescription()
-        ReadManga.curMangaChapter?.let {
-            binding.tvChapterName.text = ReadManga.book?.durChapterTitle
-            binding.tvPre.isEnabled = ReadManga.durChapterIndex != 0
-            binding.tvNext.isEnabled =
-                ReadManga.durChapterIndex != ReadManga.simulatedChapterSize - 1
-        } ?: let {
-            binding.tvChapterUrl.gone()
-        }
-    }
-
-    private fun bindEvent() = binding.run {
-        vwMenuBg.setOnClickListener { runMenuOut() }
-        titleBar.toolbar.setOnClickListener {
-            callBack.openBookInfoActivity()
-        }
-        val chapterViewClickListener = OnClickListener {
-            if (ReadConfig.readUrlInBrowser) {
-                context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
-            } else {
-                context.startActivity<WebViewActivity> {
-                    val url = tvChapterUrl.text.toString()
-                    val bookSource = ReadBook.bookSource
-                    putExtra("title", tvChapterName.text)
-                    putExtra("url", url)
-                    putExtra("sourceOrigin", bookSource?.bookSourceUrl)
-                    putExtra("sourceName", bookSource?.bookSourceName)
-                    putExtra("sourceType", bookSource?.getSourceType())
-                }
-            }
-        }
-        val chapterViewLongClickListener = OnLongClickListener {
-            context.alert(R.string.open_fun) {
-                setMessage(R.string.use_browser_open)
-                okButton {
-                    updateReadUrlInBrowser(true)
-                }
-                noButton {
-                    updateReadUrlInBrowser(false)
-                }
-            }
-            true
-        }
-        tvBookName.setOnClickListener{
-            callBack.openBookInfoActivity()
-        }
-        tvChapterName.setOnClickListener(chapterViewClickListener)
-        tvChapterName.setOnLongClickListener(chapterViewLongClickListener)
-        tvChapterUrl.setOnClickListener(chapterViewClickListener)
-        tvChapterUrl.setOnLongClickListener(chapterViewLongClickListener)
-        tvSourceAction.setOnLongClickListener {
-            sourceMenu.menu.findItem(R.id.menu_chapter_pay).isVisible =
-                !ReadManga.bookSource?.getContentRule()?.payAction.isNullOrBlank()
-            sourceMenu.show()
-            true
-        }
-        tvNext.setOnClickListener {
-            ReadManga.moveToNextChapter(true)
-        }
-        tvPre.setOnClickListener {
-            ReadManga.moveToPrevChapter(true)
-        }
-
-        seekReadPage.addOnChangeListener { _, value, fromUser ->
-            if (fromUser) {
-                HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE
-                updateSeekAccessibility(
-                    value = value.toInt(),
-                    count = seekReadPage.valueTo.toInt().coerceAtLeast(1),
-                )
-                callBack.skipToPage(value.toInt() - 1)
-            }
-        }
-
-        seekReadPage.addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
-            override fun onStartTrackingTouch(slider: Slider) {
-                HapticFeedbackConstantsCompat.TEXT_HANDLE_MOVE
-                vwMenuBg.setOnClickListener(null)
-            }
-
-            override fun onStopTrackingTouch(slider: Slider) {
-                vwMenuBg.setOnClickListener { runMenuOut() }
-            }
-        })
-
-        ivCatalog.setOnClickListener {
-            callBack.openCatalog()
-        }
-
-        ivSetting.setOnClickListener {
-            callBack.showFooterConfig()
-            runMenuOut()
-        }
-
-        btnAutoPage.setOnLongClickListener {
-            callBack.onAutoPageToggle2()
-            true
-        }
-
-        btnAutoPage.setOnClickListener {
-            callBack.showScrollModeDialog()
-        }
-
-    }
-
-    private fun updateReadUrlInBrowser(enabled: Boolean) {
-        activity?.lifecycleScope?.launch {
-            readSettingsGateway.update { it.copy(readUrlInBrowser = enabled) }
-        }
-    }
-
-    fun upSeekBar(value: Int, count: Int) {
-        binding.seekReadPage.apply {
-            if (count <= 1) {
-                isEnabled = false
-                valueFrom = 1f
-                valueTo = 2f
-                this.value = 1f
-            } else {
-                isEnabled = true
-                valueFrom = 1f
-                valueTo = count.toFloat()
-                stepSize = 1f
-                this.value = value.toFloat().coerceIn(valueFrom, valueTo)
-            }
-            updateSeekAccessibility(
-                value = this.value.toInt(),
-                count = if (count <= 1) 1 else count,
-            )
-        }
-    }
-
-    private fun updateSeekAccessibility(value: Int, count: Int) {
-        binding.seekReadPage.contentDescription = context.getString(R.string.progress)
-        ViewCompat.setStateDescription(
-            binding.seekReadPage,
-            context.getString(
-                R.string.a11y_read_progress_page,
-                value.coerceAtLeast(1),
-                count.coerceAtLeast(1),
-            )
-        )
-    }
-
-    private fun updateSourceActionDescription() {
-        binding.tvSourceAction.contentDescription = context.getString(
-            R.string.a11y_book_source_action,
-            binding.tvSourceAction.text,
-        )
-    }
-
-    interface CallBack {
-        fun openBookInfoActivity()
-        fun upSystemUiVisibility(menuIsVisible: Boolean)
-        fun skipToPage(index: Int)
-        fun openCatalog()
-        fun showFooterConfig()
-        fun showScrollModeDialog()
-        fun onAutoPageToggle2()
-        fun showLogin()
-        fun payAction()
-        fun openSourceEditActivity()
-        fun disableSource()
-    }
-
-}

+ 0 - 95
app/src/main/res/layout/activity_manga.xml

@@ -1,95 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/root_view"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent">
-
-    <io.legado.app.ui.book.manga.recyclerview.WebtoonFrame
-        android:id="@+id/webtoon_frame"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:background="@color/black">
-
-        <io.legado.app.ui.book.manga.recyclerview.WebtoonRecyclerView
-            android:id="@+id/recycler_view"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent" />
-
-    </io.legado.app.ui.book.manga.recyclerview.WebtoonFrame>
-
-    <io.legado.app.ui.widget.ReaderInfoBarView
-        android:id="@+id/infobar"
-        android:layout_width="match_parent"
-        android:layout_height="20dp"
-        android:layout_gravity="bottom"
-        android:layout_marginBottom="10dp"
-        android:paddingStart="10dp"
-        android:paddingEnd="10dp"
-        android:visibility="gone"
-        tools:visibility="visible" />
-
-    <io.legado.app.ui.book.read.MangaMenu
-        android:id="@+id/manga_menu"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:visibility="gone"
-        tools:visibility="visible" />
-
-    <FrameLayout
-        android:id="@+id/fl_loading"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
-        <LinearLayout
-            android:id="@+id/ll_loading"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:background="?attr/colorSurface"
-            android:layout_gravity="center"
-            android:gravity="center"
-            android:orientation="vertical">
-
-            <com.google.android.material.loadingindicator.LoadingIndicator
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                app:indicatorSize="64dp" />
-
-            <TextView
-                android:id="@+id/tv_loading_message"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="16dp"
-                android:textStyle="bold"
-                android:text="@string/loading" />
-        </LinearLayout>
-
-        <LinearLayout
-            android:id="@+id/ll_retry"
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:gravity="center"
-            android:orientation="vertical"
-            android:visibility="gone">
-
-            <io.legado.app.ui.widget.EmptyMessageView
-                android:id="@+id/tv_msg2"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:textColor="@color/white"
-                android:textSize="32sp" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/tv_retry"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"
-                app:icon="@drawable/ic_refresh"
-                android:layout_margin="16dp"
-                app:iconPadding="4dp"
-                android:text="@string/load_error_retry"
-                />
-        </LinearLayout>
-
-    </FrameLayout>
-</FrameLayout>

+ 0 - 63
app/src/main/res/layout/dialog_manga_auto_read.xml

@@ -1,63 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:id="@+id/rootView"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content"
-    android:orientation="vertical"
-    android:paddingBottom="16dp">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:gravity="center"
-        android:orientation="vertical"
-        android:paddingHorizontal="16dp"
-        android:paddingBottom="8dp">
-
-        <com.google.android.material.bottomsheet.BottomSheetDragHandleView
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:paddingVertical="16dp"
-            android:minHeight="0dp"
-            android:importantForAccessibility="no"
-            android:layout_gravity="center_horizontal" />
-
-        <TextView
-            android:id="@+id/bottom_sheet_title"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:text="@string/auto_next_page"
-            android:textSize="18sp"
-            android:textStyle="bold" />
-
-    </LinearLayout>
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="horizontal"
-        android:gravity="center_vertical"
-        android:paddingBottom="32dp"
-        android:paddingHorizontal="16dp">
-
-
-        <com.google.android.material.chip.Chip
-            android:id="@+id/chip_autoPage"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:checkable="true"
-            android:text="@string/enable_auto_page_scroll" />
-
-        <io.legado.app.ui.widget.SimpleSliderView
-            android:id="@+id/scv_autoPageSpeed"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            app:max="100"
-            app:min="1"
-            app:title="@string/auto_next_page_speed" />
-
-
-    </LinearLayout>
-
-</LinearLayout>

+ 0 - 153
app/src/main/res/layout/dialog_manga_color_filter.xml

@@ -1,153 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:paddingHorizontal="16dp"
-    android:paddingBottom="8dp">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center"
-            android:orientation="vertical"
-            android:paddingHorizontal="16dp"
-            android:paddingBottom="8dp">
-
-            <com.google.android.material.bottomsheet.BottomSheetDragHandleView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:paddingVertical="16dp"
-                android:minHeight="0dp"
-                android:layout_gravity="center_horizontal" />
-
-            <TextView
-                android:id="@+id/bottom_sheet_title"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/manga_color_filter"
-                android:textSize="18sp"
-                android:textStyle="bold" />
-
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_brightness"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="255"
-                app:title="@string/brightness" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/chip_autoBrightness"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:text="@string/flow_sys" />
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical"
-            android:orientation="horizontal">
-
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cp_epaper"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:layout_marginEnd="8dp"
-                android:text="@string/manga_epaper" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cp_enableGray"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:text="@string/enable_manga_gray" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsbEpaper"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                app:orientation="horizontal"
-                app:max="255"
-                app:title="阈值" />
-        </LinearLayout>
-
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_r"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="255"
-                app:title="R" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_g"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="255"
-                app:title="G" />
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center_vertical">
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_b"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="255"
-                app:title="B" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <io.legado.app.ui.widget.SimpleCounterView
-                android:id="@+id/dsb_a"
-                android:layout_width="120dp"
-                android:layout_height="wrap_content"
-                app:max="255"
-                app:title="A" />
-        </LinearLayout>
-
-    </LinearLayout>
-
-</androidx.core.widget.NestedScrollView>

+ 0 - 29
app/src/main/res/layout/dialog_manga_epaper.xml

@@ -1,29 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:padding="16dp">
-
-    <LinearLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical">
-
-        <TextView
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:paddingBottom="8dp"
-            android:text="@string/manga_epaper_stting"
-            android:textSize="18sp" />
-
-        <io.legado.app.ui.widget.DetailSeekBar
-            android:id="@+id/dsb_epaper"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            app:max="255"
-            app:title="@string/manga_epaper_value" />
-
-    </LinearLayout>
-
-</androidx.core.widget.NestedScrollView>

+ 0 - 328
app/src/main/res/layout/dialog_manga_footer_setting.xml

@@ -1,328 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:paddingBottom="8dp">
-
-    <LinearLayout
-        android:id="@+id/root_view"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:orientation="vertical">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:gravity="center"
-            android:orientation="vertical"
-            android:paddingHorizontal="16dp"
-            android:paddingBottom="8dp">
-
-            <com.google.android.material.bottomsheet.BottomSheetDragHandleView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:paddingVertical="16dp"
-                android:minHeight="0dp"
-                android:importantForAccessibility="no"
-                android:layout_gravity="center_horizontal" />
-
-            <TextView
-                android:id="@+id/bottom_sheet_title"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/manga_setting"
-                android:textSize="18sp"
-                android:textStyle="bold" />
-
-        </LinearLayout>
-
-        <TextView
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp"
-            android:paddingTop="8dp"
-            android:text="@string/read_type"
-            android:textStyle="bold" />
-
-        <TextView
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp"
-            android:paddingBottom="8dp"
-            android:textSize="9sp"
-            android:text="@string/read_type_long"
-            android:textStyle="bold"
-            tools:ignore="SmallSp" />
-
-        <com.google.android.material.chip.ChipGroup
-            android:id="@+id/chipGroupScrollMode"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="horizontal"
-            android:paddingHorizontal="16dp"
-            android:singleLine="false"
-            app:selectionRequired="true"
-            app:singleSelection="true" />
-
-        <LinearLayout
-            android:id="@+id/ll_webtoon"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical">
-
-            <TextView
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:paddingHorizontal="16dp"
-                android:paddingTop="8dp"
-                android:text="@string/webtoon"
-                android:textStyle="bold" />
-
-            <io.legado.app.ui.widget.DetailSeekBar
-                android:id="@+id/scv_Padding"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginHorizontal="16dp"
-                app:max="30"
-                app:title="侧边留白" />
-
-        </LinearLayout>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp"
-            android:gravity="center_vertical"
-            android:orientation="horizontal">
-
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginEnd="8dp"
-                android:text="@string/footer"
-                android:textStyle="bold"
-                android:textSize="14sp" />
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1" />
-
-            <com.google.android.material.chip.ChipGroup
-                android:id="@+id/rg_footer_orientation"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                app:selectionRequired="true"
-                app:singleSelection="true">
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_left"
-                    android:layout_width="34dp"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:clickable="true"
-                    app:chipIcon="@drawable/ic_align_left"
-                    app:chipIconEnabled="true" />
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_center"
-                    android:layout_width="34dp"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:clickable="true"
-                    app:chipIcon="@drawable/ic_align_center"
-                    app:chipIconEnabled="true" />
-
-                <com.google.android.material.chip.Chip
-                    android:id="@+id/rb_hide"
-                    android:layout_width="34dp"
-                    android:layout_height="wrap_content"
-                    android:checkable="true"
-                    android:clickable="true"
-                    app:chipIcon="@drawable/ic_visibility_off"
-                    app:chipIconEnabled="true" />
-            </com.google.android.material.chip.ChipGroup>
-
-        </LinearLayout>
-
-
-        <com.google.android.material.card.MaterialCardView
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginVertical="8dp"
-            app:cardBackgroundColor="?attr/colorSurfaceVariant"
-            android:layout_marginHorizontal="16dp"
-            app:cardCornerRadius="8dp"
-            app:strokeWidth="0dp">
-
-            <TextView
-                android:id="@+id/tv_chapter"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:padding="8dp" />
-        </com.google.android.material.card.MaterialCardView>
-
-        <com.google.android.material.chip.ChipGroup
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginBottom="8dp"
-            android:paddingHorizontal="16dp">
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_chapter_name"
-                android:checkable="true"
-                android:clickable="true"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/epi_3" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_page_number_label"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:clickable="true"
-                android:text="@string/manga_check_page_label" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_page_number"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:clickable="true"
-                android:text="4/30" />
-
-        </com.google.android.material.chip.ChipGroup>
-
-        <com.google.android.material.chip.ChipGroup
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp">
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_chapter_label"
-                android:checkable="true"
-                android:clickable="true"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="@string/manga_check_chapter_label" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_chapter"
-                android:checkable="true"
-                android:clickable="true"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:text="1 / 45" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_progress_ratio_label"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:clickable="true"
-                android:text="@string/manga_check_progress_label" />
-
-            <com.google.android.material.chip.Chip
-                android:id="@+id/cb_progress_ratio"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:checkable="true"
-                android:clickable="true"
-                android:text="2.1%" />
-        </com.google.android.material.chip.ChipGroup>
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:paddingHorizontal="12dp">
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxDisableClickScroll"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/disable_manga_click_scroll" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxDisableMangaScale"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/disable_manga_scale" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxScrollAnimation"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/disable_manga_scroll_animation" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxHideMangaTitle"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/hide_manga_title" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxVolumeKeyPage"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/volume_key_page" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/reverseVolumeKeyPage"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/reverse_volume_key_page" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxMangaLongClick"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/manga_long_click" />
-
-            <com.google.android.material.checkbox.MaterialCheckBox
-                android:id="@+id/checkboxMangaCrossFade"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="@string/manga_cross_fade" />
-
-        </LinearLayout>
-
-        <io.legado.app.ui.widget.AccentColorButton
-            android:id="@+id/btn_background_color"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginHorizontal="16dp"
-            android:layout_marginBottom="8dp"
-            android:text="@string/bg_color"
-            style="@style/Widget.Material3Expressive.Button.OutlinedButton"/>
-
-        <com.google.android.material.button.MaterialButtonGroup
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:paddingHorizontal="16dp">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_color_filter"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:layout_weight="1"
-                android:text="@string/manga_color_filter"
-                app:icon="@drawable/ic_filter" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_click_set"
-                style="@style/Widget.Material3Expressive.Button.TonalButton"
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:layout_weight="2"
-                android:text="@string/click_regional_config"
-                app:icon="@drawable/ic_filter" />
-
-        </com.google.android.material.button.MaterialButtonGroup>
-
-    </LinearLayout>
-
-</androidx.core.widget.NestedScrollView>

+ 0 - 38
app/src/main/res/layout/item_book_manga_edge.xml

@@ -1,38 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:gravity="center_vertical"
-    android:orientation="vertical"
-    android:padding="24dp"
-    android:background="@color/book_ant_10">
-
-    <TextView
-        android:id="@+id/text"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:autoSizeMaxTextSize="12sp"
-        android:autoSizeMinTextSize="8sp"
-        android:autoSizePresetSizes="12sp"
-        android:autoSizeStepGranularity="2dp"
-        android:autoSizeTextType="uniform"
-        android:text="@string/read_end"
-        android:textColor="@color/white"
-        android:textStyle="bold" />
-
-    <TextView
-        android:id="@+id/textChapter"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:autoSizeMaxTextSize="22sp"
-        android:autoSizeMinTextSize="12sp"
-        android:autoSizePresetSizes="22sp"
-        android:autoSizeStepGranularity="2dp"
-        android:autoSizeTextType="uniform"
-        android:text=""
-        android:textColor="@color/white"
-        android:textSize="22sp"
-        android:textStyle="bold" />
-
-
-</LinearLayout>

+ 0 - 66
app/src/main/res/layout/item_book_manga_page.xml

@@ -1,66 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/rootView"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:background="@color/book_ant_10"
-    android:contentDescription="@null">
-
-    <androidx.appcompat.widget.AppCompatImageView
-        android:id="@+id/image"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:adjustViewBounds="true"
-        android:scaleType="fitXY" />
-
-    <FrameLayout
-        android:id="@+id/fl_progress"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:background="@color/book_ant_10">
-
-        <com.google.android.material.progressindicator.CircularProgressIndicator
-            android:id="@+id/loading"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            app:trackColor="?attr/colorOnSurfaceVariant"
-            app:indicatorColor="?attr/colorPrimaryVariant"
-            android:indeterminate="false"
-            app:indicatorSize="64dp"
-            app:waveAmplitude="2dp"
-            app:wavelength="16dp"
-            app:waveSpeed="50dp"
-            android:layout_gravity="center" />
-
-
-        <com.google.android.material.button.MaterialButton
-            android:id="@+id/retry"
-            style="@style/Widget.Material3Expressive.Button.TonalButton"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:text="@string/load_error_retry"
-            android:textSize="18sp"
-            android:textColor="?attr/colorSecondaryContainer"
-            app:backgroundTint="?attr/colorOnSecondaryContainer"
-            app:cornerRadius="12dp"
-            app:icon="@drawable/ic_refresh"
-            app:iconSize="24sp"
-            app:iconTint="?attr/colorSecondaryContainer"
-            android:visibility="gone" />
-
-        <TextView
-            android:id="@+id/progress"
-            android:layout_width="wrap_content"
-            android:layout_height="wrap_content"
-            android:layout_gravity="center"
-            android:text=""
-            android:textColor="?attr/colorSurfaceVariant"
-            android:visibility="gone"
-            tools:visibility="visible" />
-
-    </FrameLayout>
-
-</FrameLayout>

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

@@ -1,208 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/vw_menu_root"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical">
-
-    <View
-        android:id="@+id/vw_menu_bg"
-        android:layout_width="match_parent"
-        android:layout_height="match_parent"
-        android:importantForAccessibility="no"
-        tools:layout_editor_absoluteX="0dp"
-        tools:layout_editor_absoluteY="0dp" />
-
-    <io.legado.app.ui.widget.TitleBar
-        android:id="@+id/title_bar"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:alpha="0.92"
-        android:background="?attr/colorSurfaceContainer"
-        app:layout_constraintTop_toTopOf="parent">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:orientation="vertical"
-            android:paddingHorizontal="16dp">
-
-            <TextView
-                android:id="@+id/tv_book_name"
-                style="@style/TextAppearance.Material3.TitleLarge"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:singleLine="true" />
-
-            <LinearLayout
-                android:id="@+id/title_bar_addition"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:orientation="horizontal">
-
-                <TextView
-                    android:id="@+id/tv_chapter_name"
-                    style="@style/TextAppearance.Material3.BodyMedium"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:layout_weight="1"
-                    android:singleLine="true" />
-
-                <TextView
-                    android:id="@+id/tv_chapter_url"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:singleLine="true"
-                    android:visibility="gone" />
-
-                <TextView
-                    android:id="@+id/tv_source_action"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:paddingVertical="8dp"
-                    android:text="@string/book_source" />
-
-            </LinearLayout>
-        </LinearLayout>
-    </io.legado.app.ui.widget.TitleBar>
-
-    <LinearLayout
-        android:id="@+id/bottom_menu"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:alpha="0.92"
-        android:importantForAccessibility="no"
-        android:orientation="vertical"
-        app:layout_constraintBottom_toBottomOf="parent">
-
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginHorizontal="16dp"
-            android:layout_marginVertical="8dp"
-            android:importantForAccessibility="no"
-            android:orientation="horizontal">
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/tv_pre"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minWidth="48dp"
-                android:minHeight="48dp"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
-                app:backgroundTint="?attr/colorSurfaceContainer"
-                android:contentDescription="@string/previous_chapter"
-                android:tooltipText="@string/previous_chapter"
-                app:icon="@drawable/ic_previous" />
-
-            <com.google.android.material.card.MaterialCardView
-                android:layout_width="0dp"
-                android:layout_height="wrap_content"
-                android:layout_weight="1"
-                app:cardBackgroundColor="?attr/colorSurfaceContainer"
-                app:cardCornerRadius="32dp"
-                app:strokeWidth="0dp">
-
-                <com.google.android.material.slider.Slider
-                    android:id="@+id/seek_read_page"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:contentDescription="@string/progress"
-                    android:layout_gravity="center_vertical" />
-            </com.google.android.material.card.MaterialCardView>
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/tv_next"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:minWidth="48dp"
-                android:minHeight="48dp"
-                style="@style/Widget.Material3Expressive.Button.IconButton.Tonal"
-                app:backgroundTint="?attr/colorSurfaceContainer"
-                android:contentDescription="@string/next_chapter"
-                android:tooltipText="@string/next_chapter"
-                app:icon="@drawable/ic_next" />
-        </LinearLayout>
-
-        <LinearLayout
-            android:id="@+id/bottom_view"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:background="?attr/colorSurfaceContainer"
-            android:importantForAccessibility="no"
-            android:orientation="horizontal"
-            android:paddingHorizontal="16dp"
-            android:paddingVertical="8dp">
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"
-                android:importantForAccessibility="no" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_catalog"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.IconButton"
-                android:background="?attr/selectableItemBackgroundBorderless"
-                app:iconTint="?attr/colorPrimary"
-                android:contentDescription="@string/chapter_list"
-                android:maxHeight="24dp"
-                android:tooltipText="@string/chapter_list"
-                app:icon="@drawable/ic_toc"
-                app:iconGravity="textStart"
-                tools:ignore="NestedWeights" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"
-                android:importantForAccessibility="no" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/btn_autoPage"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.IconButton"
-                android:background="?attr/selectableItemBackgroundBorderless"
-                app:iconTint="?attr/colorPrimary"
-                android:contentDescription="@string/auto_next_page"
-                android:tooltipText="@string/auto_next_page"
-                app:icon="@drawable/ic_auto_page"
-                app:iconGravity="textStart"
-                tools:ignore="UnusedAttribute" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"
-                android:importantForAccessibility="no" />
-
-            <com.google.android.material.button.MaterialButton
-                android:id="@+id/iv_setting"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                style="@style/Widget.Material3Expressive.Button.IconButton"
-                android:background="?attr/selectableItemBackgroundBorderless"
-                app:iconTint="?attr/colorPrimary"
-                android:contentDescription="@string/setting"
-                android:maxHeight="24dp"
-                android:tooltipText="@string/setting"
-                app:icon="@drawable/ic_settings"
-                app:iconGravity="textStart"
-                tools:ignore="NestedWeights" />
-
-            <View
-                android:layout_width="0dp"
-                android:layout_height="match_parent"
-                android:layout_weight="1"
-                android:importantForAccessibility="no" />
-
-        </LinearLayout>
-
-    </LinearLayout>
-
-</androidx.constraintlayout.widget.ConstraintLayout>

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

@@ -1,28 +0,0 @@
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:context=".ui.main.MainActivity"
-    tools:ignore="AlwaysShowAction">
-
-    <group android:id="@+id/menu_group_on_line">
-        <item
-            android:id="@+id/menu_change_source"
-            android:icon="@drawable/ic_exchange"
-            android:title="@string/change_origin"
-            app:showAsAction="always" />
-
-        <item
-            android:id="@+id/menu_refresh"
-            android:icon="@drawable/ic_refresh"
-            android:title="@string/refresh"
-            app:showAsAction="always" />
-
-    </group>
-
-    <item
-        android:id="@+id/menu_pre_manga_number"
-        android:title="@string/pre_download"
-        app:showAsAction="never" />
-
-
-</menu>

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

@@ -2906,4 +2906,71 @@
     <string name="whole_book_page_estimated_format">全文 %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">全文 - / -</string>
     <string name="translation_model_thinking">模型思考中</string>
+    <string name="manga_reader_auto_read">自动阅读</string>
+    <string name="manga_reader_display_filter">显示与滤镜</string>
+    <string name="manga_reader_click_areas">九宫格点击区域</string>
+    <string name="manga_reader_source_actions">书源操作</string>
+    <string name="manga_reader_webtoon_gap">条漫(间隔)</string>
+    <string name="manga_reader_left_to_right">从左到右</string>
+    <string name="manga_reader_right_to_left">从右到左</string>
+    <string name="manga_reader_top_to_bottom">从上到下</string>
+    <string name="manga_reader_side_padding">条漫侧边留白</string>
+    <string name="manga_reader_preload_pages">预加载页数</string>
+    <string name="manga_reader_red">红</string>
+    <string name="manga_reader_green">绿</string>
+    <string name="manga_reader_blue">蓝</string>
+    <string name="manga_reader_pinch_zoom">允许双指缩放</string>
+    <string name="manga_reader_tap_turn">点击区域翻页</string>
+    <string name="manga_reader_scroll_animation">滚动动画</string>
+    <string name="manga_reader_image_fade">图片淡入</string>
+    <string name="manga_reader_long_press_save">长按保存图片</string>
+    <string name="manga_reader_volume_page">音量键翻页</string>
+    <string name="manga_reader_reverse_volume">反转音量键</string>
+    <string name="manga_reader_hide_edge_prompt">隐藏章节首尾提示</string>
+    <string name="manga_reader_hide_footer">隐藏页脚</string>
+    <string name="manga_reader_hide_chapter_name">隐藏章节名</string>
+    <string name="manga_reader_hide_page_number">隐藏页码</string>
+    <string name="manga_reader_hide_page_label">隐藏页码标签</string>
+    <string name="manga_reader_hide_chapter_progress">隐藏章节进度</string>
+    <string name="manga_reader_hide_chapter_label">隐藏章节标签</string>
+    <string name="manga_reader_hide_total_progress">隐藏总进度</string>
+    <string name="manga_reader_hide_progress_label">隐藏总进度标签</string>
+    <string name="manga_reader_footer_alignment">页脚对齐</string>
+    <string name="manga_reader_left_align">左对齐</string>
+    <string name="manga_reader_center_align">居中</string>
+    <string name="manga_reader_enable_auto_read">启用自动阅读</string>
+    <string name="manga_reader_auto_speed">翻页/滚动速度</string>
+    <string name="manga_reader_grayscale">灰度模式</string>
+    <string name="manga_reader_eink">墨水屏模式</string>
+    <string name="manga_reader_eink_threshold">墨水屏阈值</string>
+    <string name="manga_reader_filter_red">红色</string>
+    <string name="manga_reader_filter_green">绿色</string>
+    <string name="manga_reader_filter_blue">蓝色</string>
+    <string name="manga_reader_filter_alpha">透明度</string>
+    <string name="manga_reader_system_brightness">跟随系统亮度</string>
+    <string name="manga_reader_screen_brightness">屏幕亮度</string>
+    <string name="manga_reader_menu">菜单</string>
+    <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_buy_chapter">购买当前章节</string>
+    <string name="manga_reader_page_description">%1$s,第 %2$d 页</string>
+    <string name="manga_reader_page_label">页码</string>
+    <string name="manga_reader_chapter_label">章节</string>
+    <string name="manga_reader_progress_label">进度</string>
+    <string name="manga_reader_page_semantics">阅读页码</string>
+    <string name="manga_reader_auto_short">自动</string>
+    <string name="manga_reader_page_settings">翻页设置</string>
+    <string name="manga_reader_filter_short">滤镜</string>
+    <string name="manga_reader_click_area_short">点击区域</string>
+    <string name="manga_reader_preload_short">预加载</string>
+    <string name="manga_reader_added_to_shelf">已添加到书架</string>
+    <string name="manga_reader_init_failed">初始化数据失败\n%1$s</string>
+    <string name="manga_reader_image_saved">图片已保存到相册</string>
+    <string name="manga_reader_save_failed">保存失败</string>
+    <string name="manga_reader_action_failed">操作失败</string>
+    <string name="manga_reader_details_failed">详情加载失败</string>
+    <string name="manga_reader_details_error">详情页出错:%1$s</string>
+    <string name="manga_reader_no_source">没有合适书源</string>
+    <string name="manga_reader_auto_source_failed">自动换源失败</string>
 </resources>

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

@@ -2911,4 +2911,71 @@
     <string name="whole_book_page_estimated_format">Book %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">Book - / -</string>
     <string name="translation_model_thinking">Model is thinking</string>
+    <string name="manga_reader_auto_read">Auto read</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>
+    <string name="manga_reader_webtoon_gap">Webtoon with gaps</string>
+    <string name="manga_reader_left_to_right">Left to right</string>
+    <string name="manga_reader_right_to_left">Right to left</string>
+    <string name="manga_reader_top_to_bottom">Top to bottom</string>
+    <string name="manga_reader_side_padding">Webtoon side padding</string>
+    <string name="manga_reader_preload_pages">Preload pages</string>
+    <string name="manga_reader_red">Red</string>
+    <string name="manga_reader_green">Green</string>
+    <string name="manga_reader_blue">Blue</string>
+    <string name="manga_reader_pinch_zoom">Allow pinch to zoom</string>
+    <string name="manga_reader_tap_turn">Tap areas turn pages</string>
+    <string name="manga_reader_scroll_animation">Scroll animation</string>
+    <string name="manga_reader_image_fade">Image fade-in</string>
+    <string name="manga_reader_long_press_save">Long press to save image</string>
+    <string name="manga_reader_volume_page">Volume keys turn pages</string>
+    <string name="manga_reader_reverse_volume">Reverse volume keys</string>
+    <string name="manga_reader_hide_edge_prompt">Hide chapter edge prompts</string>
+    <string name="manga_reader_hide_footer">Hide footer</string>
+    <string name="manga_reader_hide_chapter_name">Hide chapter name</string>
+    <string name="manga_reader_hide_page_number">Hide page number</string>
+    <string name="manga_reader_hide_page_label">Hide page label</string>
+    <string name="manga_reader_hide_chapter_progress">Hide chapter progress</string>
+    <string name="manga_reader_hide_chapter_label">Hide chapter label</string>
+    <string name="manga_reader_hide_total_progress">Hide total progress</string>
+    <string name="manga_reader_hide_progress_label">Hide progress label</string>
+    <string name="manga_reader_footer_alignment">Footer alignment</string>
+    <string name="manga_reader_left_align">Left aligned</string>
+    <string name="manga_reader_center_align">Centered</string>
+    <string name="manga_reader_enable_auto_read">Enable auto read</string>
+    <string name="manga_reader_auto_speed">Page/scroll speed</string>
+    <string name="manga_reader_grayscale">Grayscale mode</string>
+    <string name="manga_reader_eink">E-ink mode</string>
+    <string name="manga_reader_eink_threshold">E-ink threshold</string>
+    <string name="manga_reader_filter_red">Red filter</string>
+    <string name="manga_reader_filter_green">Green filter</string>
+    <string name="manga_reader_filter_blue">Blue filter</string>
+    <string name="manga_reader_filter_alpha">Filter opacity</string>
+    <string name="manga_reader_system_brightness">Follow system brightness</string>
+    <string name="manga_reader_screen_brightness">Screen brightness</string>
+    <string name="manga_reader_menu">Menu</string>
+    <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_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>
+    <string name="manga_reader_chapter_label">Chapter</string>
+    <string name="manga_reader_progress_label">Progress</string>
+    <string name="manga_reader_page_semantics">Reading page</string>
+    <string name="manga_reader_auto_short">Auto</string>
+    <string name="manga_reader_page_settings">Page settings</string>
+    <string name="manga_reader_filter_short">Filter</string>
+    <string name="manga_reader_click_area_short">Tap areas</string>
+    <string name="manga_reader_preload_short">Preload</string>
+    <string name="manga_reader_added_to_shelf">Added to bookshelf</string>
+    <string name="manga_reader_init_failed">Failed to initialize data\n%1$s</string>
+    <string name="manga_reader_image_saved">Image saved to gallery</string>
+    <string name="manga_reader_save_failed">Save failed</string>
+    <string name="manga_reader_action_failed">Action failed</string>
+    <string name="manga_reader_details_failed">Failed to load details</string>
+    <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>
 </resources>

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

@@ -0,0 +1,56 @@
+package io.legado.app.ui.book.manga
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class MangaReaderInteractionTest {
+
+    @Test
+    fun `nine grid maps every cell to its configured index`() {
+        val expected = (0..8).toList()
+        val actual = buildList {
+            repeat(3) { row ->
+                repeat(3) { column ->
+                    add(
+                        mangaClickRegionIndex(
+                            x = column * 300f + 150f,
+                            y = row * 600f + 300f,
+                            width = 900,
+                            height = 1800,
+                        )
+                    )
+                }
+            }
+        }
+
+        assertEquals(expected, actual)
+    }
+
+    @Test
+    fun `nine grid clamps touches on viewport edges`() {
+        assertEquals(0, mangaClickRegionIndex(-20f, -20f, 900, 1800))
+        assertEquals(8, mangaClickRegionIndex(920f, 1820f, 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))
+    }
+
+    @Test
+    fun `page step returns item target inside chapter`() {
+        assertEquals(4, mangaPageStepTarget(currentIndex = 3, itemCount = 8, direction = 1))
+        assertEquals(2, mangaPageStepTarget(currentIndex = 3, itemCount = 8, direction = -1))
+    }
+
+    @Test
+    fun `page step delegates to chapter navigation at list boundaries`() {
+        assertNull(mangaPageStepTarget(currentIndex = 0, itemCount = 8, direction = -1))
+        assertNull(mangaPageStepTarget(currentIndex = 7, itemCount = 8, direction = 1))
+        assertNull(mangaPageStepTarget(currentIndex = 0, itemCount = 0, direction = 1))
+    }
+}

+ 0 - 1
build.gradle.kts

@@ -278,7 +278,6 @@ val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
             "io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt" to 1,
             "io/legado/app/ui/book/group/GroupManageDialog.kt" to 2,
             "io/legado/app/ui/book/group/GroupSelectDialog.kt" to 1,
-            "io/legado/app/ui/book/manga/ReadMangaActivity.kt" to 1,
             "io/legado/app/ui/book/read/ReadBookController.kt" to 3,
             "io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt" to 1,
             // 护栏缺席期间 main 新增(整书页码估算),随合并冻结