Quellcode durchsuchen

feat: 漫画相关

HapeLee vor 3 Monaten
Ursprung
Commit
e44944804a

+ 9 - 11
app/src/main/java/io/legado/app/data/repository/MangaReaderSessionRepository.kt

@@ -15,11 +15,11 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book.ReadConfig
 import io.legado.app.data.entities.Book.ReadConfig
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 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.BackupSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocalModified
 import io.legado.app.help.book.isLocalModified
@@ -28,11 +28,11 @@ import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.source.getSourceType
 import io.legado.app.help.source.getSourceType
 import io.legado.app.help.storage.Backup
 import io.legado.app.help.storage.Backup
 import io.legado.app.model.ReadManga
 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
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.manga.MangaContent
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isAbsUrl
@@ -41,13 +41,12 @@ import io.legado.app.utils.mapParallelSafe
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.postEvent
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.currentCoroutineContext
 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.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
@@ -139,7 +138,8 @@ class MangaReaderSessionRepository(
         bookName = ReadManga.book?.name.orEmpty(),
         bookName = ReadManga.book?.name.orEmpty(),
         bookAuthor = ReadManga.book?.author.orEmpty(),
         bookAuthor = ReadManga.book?.author.orEmpty(),
         bookUrl = ReadManga.book?.bookUrl.orEmpty(),
         bookUrl = ReadManga.book?.bookUrl.orEmpty(),
-        chapterName = ReadManga.book?.durChapterTitle.orEmpty(),
+        chapterName = ReadManga.curMangaChapter?.chapter?.title
+            ?: ReadManga.book?.durChapterTitle.orEmpty(),
         chapterUrl = ReadManga.curMangaChapter?.chapter?.url,
         chapterUrl = ReadManga.curMangaChapter?.chapter?.url,
         sourceName = ReadManga.bookSource?.bookSourceName.orEmpty(),
         sourceName = ReadManga.bookSource?.bookSourceName.orEmpty(),
         sourceUrl = ReadManga.bookSource?.bookSourceUrl,
         sourceUrl = ReadManga.bookSource?.bookSourceUrl,
@@ -173,12 +173,10 @@ class MangaReaderSessionRepository(
     fun applyProgress(progress: BookProgress) = ReadManga.setProgress(progress)
     fun applyProgress(progress: BookProgress) = ReadManga.setProgress(progress)
 
 
     fun setVisiblePage(chapterIndex: Int, pageIndex: Int) {
     fun setVisiblePage(chapterIndex: Int, pageIndex: Int) {
-        when {
-            ReadManga.durChapterIndex < chapterIndex -> ReadManga.moveToNextChapter()
-            ReadManga.durChapterIndex > chapterIndex -> ReadManga.moveToPrevChapter()
+        if (ReadManga.durChapterIndex == chapterIndex) {
+            ReadManga.durChapterPos = pageIndex
+            ReadManga.curPageChanged()
         }
         }
-        ReadManga.durChapterPos = pageIndex
-        ReadManga.curPageChanged()
     }
     }
 
 
     fun effectiveScrollMode(defaultValue: Int): Int =
     fun effectiveScrollMode(defaultValue: Int): Int =

+ 11 - 12
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -1,12 +1,12 @@
 package io.legado.app.ui.book.manga
 package io.legado.app.ui.book.manga
 
 
+import androidx.annotation.StringRes
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.Stable
-import androidx.annotation.StringRes
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
-import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookProgress
 import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.book.manga.config.MangaScrollMode
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentListOf
@@ -35,6 +35,7 @@ data class MangaReaderUiState(
     val autoReadEnabled: Boolean = false,
     val autoReadEnabled: Boolean = false,
     val activeSheet: MangaReaderSheet? = null,
     val activeSheet: MangaReaderSheet? = null,
     val activeDialog: MangaReaderDialog? = null,
     val activeDialog: MangaReaderDialog? = null,
+    val settingsCategory: MangaReaderSettingsCategory? = null,
     val inBookshelf: Boolean = true,
     val inBookshelf: Boolean = true,
     val confirmAddToShelf: Boolean = true,
     val confirmAddToShelf: Boolean = true,
     val scrollRequest: MangaScrollRequest? = null,
     val scrollRequest: MangaScrollRequest? = null,
@@ -159,11 +160,8 @@ sealed interface MangaReaderIntent {
     data object OpenChapterUrl : MangaReaderIntent
     data object OpenChapterUrl : MangaReaderIntent
     data object ChangeSource : MangaReaderIntent
     data object ChangeSource : MangaReaderIntent
     data object RefreshChapter : 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 class OpenSettings(val category: MangaReaderSettingsCategory) : MangaReaderIntent
+    data object CloseSettings : MangaReaderIntent
     data object OpenSourceActions : MangaReaderIntent
     data object OpenSourceActions : MangaReaderIntent
     data object ToggleAutoRead : MangaReaderIntent
     data object ToggleAutoRead : MangaReaderIntent
     data object DismissSheet : MangaReaderIntent
     data object DismissSheet : MangaReaderIntent
@@ -174,21 +172,22 @@ sealed interface MangaReaderIntent {
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class PageStep(val direction: Int) : MangaReaderIntent
     data class PageStep(val direction: Int) : MangaReaderIntent
     data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
     data class SeekToPage(val pageIndex: Int) : MangaReaderIntent
-    data class VisibleItemChanged(val itemIndex: Int) : MangaReaderIntent
+    data class VisibleItemChanged(
+        val itemIndex: Int,
+        val currentChapterVisible: Boolean,
+    ) : MangaReaderIntent
     data class LongPressPage(val imageUrl: String) : MangaReaderIntent
     data class LongPressPage(val imageUrl: String) : MangaReaderIntent
     data class MessageShown(val id: Long) : MangaReaderIntent
     data class MessageShown(val id: Long) : MangaReaderIntent
 }
 }
 
 
 sealed interface MangaReaderSheet {
 sealed interface MangaReaderSheet {
     data object Catalog : MangaReaderSheet
     data object Catalog : MangaReaderSheet
-    data object Reader : MangaReaderSheet
-    data object AutoRead : MangaReaderSheet
-    data object ColorFilter : MangaReaderSheet
-    data object ClickActions : MangaReaderSheet
     data object ChangeSource : MangaReaderSheet
     data object ChangeSource : MangaReaderSheet
     data object SourceActions : MangaReaderSheet
     data object SourceActions : MangaReaderSheet
 }
 }
 
 
+enum class MangaReaderSettingsCategory { READER, FOOTER, FILTER, CLICK_ACTIONS, AUTO_READ }
+
 sealed interface MangaReaderDialog {
 sealed interface MangaReaderDialog {
     data object AddToShelf : MangaReaderDialog
     data object AddToShelf : MangaReaderDialog
     data class ConfirmPay(val chapterName: String) : MangaReaderDialog
     data class ConfirmPay(val chapterName: String) : MangaReaderDialog

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

@@ -30,3 +30,23 @@ internal fun mangaPageStepTarget(
 
 
 internal fun shouldExposeMangaPages(currentChapterFinished: Boolean): Boolean =
 internal fun shouldExposeMangaPages(currentChapterFinished: Boolean): Boolean =
     currentChapterFinished
     currentChapterFinished
+
+enum class MangaChapterSwitch { NONE, NEXT, PREVIOUS }
+
+/**
+ * 决定可见页是否触发章节切换:只有当前章节已完全滑出视口时,
+ * 才允许沿可见页的章节方向自动切章;否则只是停留在当前章记录进度。
+ */
+internal fun mangaChapterSwitchDecision(
+    currentChapterIndex: Int,
+    visibleChapterIndex: Int,
+    currentChapterVisible: Boolean,
+): MangaChapterSwitch = when {
+    currentChapterIndex < visibleChapterIndex ->
+        if (currentChapterVisible) MangaChapterSwitch.NONE else MangaChapterSwitch.NEXT
+
+    currentChapterIndex > visibleChapterIndex ->
+        if (currentChapterVisible) MangaChapterSwitch.NONE else MangaChapterSwitch.PREVIOUS
+
+    else -> MangaChapterSwitch.NONE
+}

+ 102 - 49
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderOverlays.kt

@@ -1,5 +1,11 @@
 package io.legado.app.ui.book.manga
 package io.legado.app.ui.book.manga
 
 
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -9,20 +15,18 @@ import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.statusBarsPadding
 import androidx.compose.foundation.layout.statusBarsPadding
-import androidx.compose.foundation.layout.height
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.automirrored.filled.MenuBook
 import androidx.compose.material.icons.automirrored.filled.MenuBook
 import androidx.compose.material.icons.filled.AutoMode
 import androidx.compose.material.icons.filled.AutoMode
 import androidx.compose.material.icons.filled.FilterAlt
 import androidx.compose.material.icons.filled.FilterAlt
-import androidx.compose.material.icons.filled.FormatAlignJustify
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.SwapHoriz
 import androidx.compose.material.icons.filled.TouchApp
 import androidx.compose.material.icons.filled.TouchApp
 import androidx.compose.material.icons.filled.Tune
 import androidx.compose.material.icons.filled.Tune
@@ -148,56 +152,105 @@ internal fun BoxScope.MangaReaderMenu(
         visible = state.menuVisible,
         visible = state.menuVisible,
     ) {
     ) {
         DefaultReaderMenuBottomSurface {
         DefaultReaderMenuBottomSurface {
-            Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
-                if (state.pageCount > 1) {
-                    Row(
-                        modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
-                        verticalAlignment = Alignment.CenterVertically,
-                        horizontalArrangement = Arrangement.spacedBy(8.dp),
-                    ) {
-                        ReaderMenuIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.previous_chapter)) {
-                            onIntent(MangaReaderIntent.PreviousChapter)
+            AnimatedContent(
+                targetState = state.settingsCategory != null,
+                transitionSpec = {
+                    (expandVertically(expandFrom = Alignment.Bottom) + fadeIn())
+                        .togetherWith(shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut())
+                },
+                label = "mangaMenuExpand",
+            ) { isSettings ->
+                if (isSettings) {
+                    MangaSettingsPanel(state, onIntent)
+                } else {
+                    Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
+                        if (state.pageCount > 1) {
+                            Row(
+                                modifier = Modifier.fillMaxWidth()
+                                    .padding(horizontal = 16.dp, vertical = 4.dp),
+                                verticalAlignment = Alignment.CenterVertically,
+                                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                            ) {
+                                ReaderMenuIconButton(
+                                    Icons.AutoMirrored.Filled.ArrowBack,
+                                    stringResource(R.string.previous_chapter)
+                                ) {
+                                    onIntent(MangaReaderIntent.PreviousChapter)
+                                }
+                                ReaderMenuSlider(
+                                    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),
+                                    accessibilityLabel = readingPageDescription,
+                                    modifier = Modifier.weight(1f),
+                                )
+                                ReaderMenuIconButton(
+                                    Icons.AutoMirrored.Filled.ArrowForward,
+                                    stringResource(R.string.next_chapter)
+                                ) {
+                                    onIntent(MangaReaderIntent.NextChapter)
+                                }
+                            }
                         }
                         }
-                        ReaderMenuSlider(
-                            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),
-                            accessibilityLabel = readingPageDescription,
-                            modifier = Modifier.weight(1f),
+                        Spacer(Modifier.height(12.dp))
+                        ReaderMenuToolRow(
+                            listOf(
+                                ReaderMenuAction(
+                                    Icons.AutoMirrored.Filled.MenuBook,
+                                    stringResource(R.string.chapter_list)
+                                ) {
+                                    onIntent(MangaReaderIntent.OpenCatalog)
+                                },
+                                ReaderMenuAction(
+                                    icon = Icons.Filled.AutoMode,
+                                    description = if (state.autoReadEnabled) stringResource(R.string.stop) else stringResource(
+                                        R.string.manga_reader_auto_short
+                                    ),
+                                    onClick = { onIntent(MangaReaderIntent.ToggleAutoRead) },
+                                    onLongClick = {
+                                        onIntent(
+                                            MangaReaderIntent.OpenSettings(
+                                                MangaReaderSettingsCategory.AUTO_READ
+                                            )
+                                        )
+                                    },
+                                ),
+                                ReaderMenuAction(
+                                    Icons.Filled.Tune,
+                                    stringResource(R.string.manga_reader_page_settings)
+                                ) {
+                                    onIntent(
+                                        MangaReaderIntent.OpenSettings(
+                                            MangaReaderSettingsCategory.READER
+                                        )
+                                    )
+                                },
+                                ReaderMenuAction(
+                                    Icons.Filled.FilterAlt,
+                                    stringResource(R.string.manga_reader_filter_short)
+                                ) {
+                                    onIntent(
+                                        MangaReaderIntent.OpenSettings(
+                                            MangaReaderSettingsCategory.FILTER
+                                        )
+                                    )
+                                },
+                                ReaderMenuAction(
+                                    Icons.Filled.TouchApp,
+                                    stringResource(R.string.manga_reader_click_area_short)
+                                ) {
+                                    onIntent(
+                                        MangaReaderIntent.OpenSettings(
+                                            MangaReaderSettingsCategory.CLICK_ACTIONS
+                                        )
+                                    )
+                                },
+                            )
                         )
                         )
-                        ReaderMenuIconButton(Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.next_chapter)) {
-                            onIntent(MangaReaderIntent.NextChapter)
-                        }
                     }
                     }
                 }
                 }
-                Spacer(Modifier.height(12.dp))
-                ReaderMenuToolRow(listOf(
-                    ReaderMenuAction(Icons.AutoMirrored.Filled.MenuBook, stringResource(R.string.chapter_list)) {
-                        onIntent(MangaReaderIntent.OpenCatalog)
-                    },
-                    ReaderMenuAction(Icons.Filled.AutoMode, if (state.autoReadEnabled) stringResource(R.string.stop) else stringResource(R.string.manga_reader_auto_short)) {
-                        onIntent(MangaReaderIntent.ToggleAutoRead)
-                    },
-                    ReaderMenuAction(Icons.Filled.Tune, stringResource(R.string.manga_reader_page_settings)) {
-                        onIntent(MangaReaderIntent.OpenAutoReadSettings)
-                    },
-                    ReaderMenuAction(Icons.Filled.FormatAlignJustify, stringResource(R.string.footer)) {
-                        onIntent(MangaReaderIntent.OpenReaderSettings)
-                    },
-                    ReaderMenuAction(Icons.Filled.FilterAlt, stringResource(R.string.manga_reader_filter_short)) {
-                        onIntent(MangaReaderIntent.OpenColorFilter)
-                    },
-                ))
-                Spacer(Modifier.height(12.dp))
-                ReaderMenuToolRow(listOf(
-                    ReaderMenuAction(Icons.Filled.TouchApp, stringResource(R.string.manga_reader_click_area_short)) {
-                        onIntent(MangaReaderIntent.OpenClickSettings)
-                    },
-                    ReaderMenuAction(Icons.Filled.Settings, stringResource(R.string.manga_reader_preload_short)) {
-                        onIntent(MangaReaderIntent.OpenPreDownloadSettings)
-                    },
-                ))
             }
             }
         }
         }
     }
     }

+ 220 - 31
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -1,16 +1,22 @@
 package io.legado.app.ui.book.manga
 package io.legado.app.ui.book.manga
 
 
+import android.widget.Toast
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.BackHandler
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.LinearEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.gestures.awaitEachGesture
+import androidx.compose.foundation.gestures.awaitFirstDown
+import androidx.compose.foundation.gestures.calculateCentroid
+import androidx.compose.foundation.gestures.calculatePan
+import androidx.compose.foundation.gestures.calculateZoom
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.gestures.rememberTransformableState
 import androidx.compose.foundation.gestures.rememberTransformableState
 import androidx.compose.foundation.gestures.transformable
 import androidx.compose.foundation.gestures.transformable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,6 +36,7 @@ import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.staticCompositionLocalOf
 import androidx.compose.runtime.staticCompositionLocalOf
@@ -43,38 +50,84 @@ import androidx.compose.ui.graphics.ColorMatrix
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.layout
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.positionInRoot
 import androidx.compose.ui.layout.positionInRoot
+import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 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.unit.IntSize
-import android.widget.Toast
+import androidx.compose.ui.unit.dp
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.repeatOnLifecycle
 import androidx.lifecycle.repeatOnLifecycle
-import androidx.compose.ui.layout.onSizeChanged
 import coil3.ImageLoader
 import coil3.ImageLoader
 import coil3.compose.AsyncImage
 import coil3.compose.AsyncImage
 import coil3.request.ImageRequest
 import coil3.request.ImageRequest
 import coil3.request.crossfade
 import coil3.request.crossfade
 import coil3.request.transformations
 import coil3.request.transformations
-import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.help.coil.CoverExtras
+import io.legado.app.ui.book.manga.config.MangaScrollMode
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject
 import kotlin.math.ceil
 import kotlin.math.ceil
+import kotlin.math.roundToInt
 
 
 private val LocalReaderViewportSize = staticCompositionLocalOf { IntSize.Zero }
 private val LocalReaderViewportSize = staticCompositionLocalOf { IntSize.Zero }
 private val LocalMangaAspectRatios = staticCompositionLocalOf<MutableMap<String, Float>> {
 private val LocalMangaAspectRatios = staticCompositionLocalOf<MutableMap<String, Float>> {
     mutableMapOf()
     mutableMapOf()
 }
 }
 
 
+private const val MIN_WEBTOON_ZOOM = 0.5f
+private const val MAX_WEBTOON_ZOOM = 3f
+private const val WEBTOON_DOUBLE_TAP_ZOOM = 2.5f
+private const val MIN_PAGE_ZOOM = 0.5f
+private const val MAX_PAGE_ZOOM = 3f
+
+/**
+ * 条漫内容缩放:把布局尺寸按 zoom 缩放(渲染缩放由 [Modifier.graphicsLayer] 完成),
+ * 使 LazyColumn 的滚动范围随缩放变化,缩小后仍能滚动到全部图片;放大时横向超出部分被裁剪。
+ */
+private fun Modifier.zoomStripItem(zoom: Float): Modifier = this.layout { measurable, constraints ->
+    val placeable = measurable.measure(constraints)
+    val width = (placeable.width * zoom).roundToInt()
+        .coerceIn(constraints.minWidth, constraints.maxWidth)
+        .coerceAtLeast(1)
+    val height = (placeable.height * zoom).roundToInt()
+        .coerceIn(constraints.minHeight, constraints.maxHeight)
+        .coerceAtLeast(1)
+    layout(width, height) {
+        placeable.placeRelative(0, 0)
+    }
+}
+
+/**
+ * 放大后的平移边界:横向限制在单页宽度溢出的范围内,纵向限制在放大后的内容高度内。
+ */
+internal fun clampZoomPan(
+    target: Offset,
+    zoom: Float,
+    itemWidth: Float,
+    contentHeight: Float,
+    viewport: IntSize,
+): Offset {
+    if (contentHeight <= 0f) return Offset.Zero
+    val width = viewport.width.coerceAtLeast(1).toFloat()
+    val height = viewport.height.coerceAtLeast(1).toFloat()
+    val maxX = ((itemWidth * zoom - width) / 2f).coerceAtLeast(0f)
+    val maxY = (contentHeight * zoom - height).coerceAtLeast(0f)
+    return Offset(
+        x = target.x.coerceIn(-maxX, maxX),
+        y = target.y.coerceIn(-maxY, 0f),
+    )
+}
+
 @Composable
 @Composable
 fun MangaReaderScreen(
 fun MangaReaderScreen(
     state: MangaReaderUiState,
     state: MangaReaderUiState,
@@ -91,10 +144,13 @@ fun MangaReaderScreen(
         state.settings.autoReadSpeed,
         state.settings.autoReadSpeed,
         state.menuVisible,
         state.menuVisible,
         state.activeSheet,
         state.activeSheet,
+        state.settingsCategory,
     ) {
     ) {
         val isWebtoon = state.settings.scrollMode == MangaScrollMode.WEBTOON ||
         val isWebtoon = state.settings.scrollMode == MangaScrollMode.WEBTOON ||
                 state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
                 state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
-        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null || isWebtoon) {
+        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null ||
+            state.settingsCategory != null || isWebtoon
+        ) {
             return@LaunchedEffect
             return@LaunchedEffect
         }
         }
         while (true) {
         while (true) {
@@ -153,10 +209,8 @@ fun MangaReaderScreen(
             ReaderStatusOverlay(state, onIntent)
             ReaderStatusOverlay(state, onIntent)
         }
         }
     }
     }
-    state.activeSheet?.takeIf {
-        it != MangaReaderSheet.ChangeSource && it != MangaReaderSheet.Catalog
-    }?.let { sheet ->
-        MangaReaderSettingsSheet(sheet, state, onIntent)
+    if (state.activeSheet == MangaReaderSheet.SourceActions) {
+        MangaReaderSourceActionsSheet(state, onIntent)
     }
     }
     if (state.activeSheet == MangaReaderSheet.ChangeSource) {
     if (state.activeSheet == MangaReaderSheet.ChangeSource) {
         val oldBook = remember(state.changeSourceBook) {
         val oldBook = remember(state.changeSourceBook) {
@@ -229,14 +283,113 @@ private fun WebtoonMangaList(
     imageLoader: ImageLoader,
     imageLoader: ImageLoader,
 ) {
 ) {
     val listState = rememberLazyListState(initialFirstVisibleItemIndex = state.currentItemIndex)
     val listState = rememberLazyListState(initialFirstVisibleItemIndex = state.currentItemIndex)
+    val viewportSize = LocalReaderViewportSize.current
+    val aspectRatios = LocalMangaAspectRatios.current
+    var zoom by remember { mutableFloatStateOf(1f) }
+    var pan by remember { mutableStateOf(Offset.Zero) }
+    val fraction = 1f - state.settings.sidePaddingPercent.coerceIn(0, 45) * 2f / 100f
+
+    // 估算自然内容高度(放大后用于限制平移范围);未加载的图片用整屏高度兜底
+    val density = LocalDensity.current
+    val fallbackPageHeightPx = with(density) { LocalConfiguration.current.screenHeightDp.dp.toPx() }
+    val edgeHeightPx = with(density) { 96.dp.toPx() }
+    val gapPx = with(density) { 8.dp.toPx() }
+    val itemWidthPx = viewportSize.width * fraction
+    val hasGap = state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP
+    val naturalContentHeight = state.pages.fold(0f) { acc, item ->
+        val itemHeight = when (item) {
+            is MangaReaderItemUi.Page -> {
+                val ratio = aspectRatios[item.key]
+                if (ratio != null && ratio > 0f) itemWidthPx / ratio else fallbackPageHeightPx
+            }
+
+            is MangaReaderItemUi.ChapterEdge -> edgeHeightPx
+        }
+        acc + itemHeight + if (hasGap) gapPx else 0f
+    }
+    // transformable 回调是 remember 住的,包一层 always-current 的值
+    val latestNaturalContentHeight by rememberUpdatedState(naturalContentHeight)
+    val latestItemWidthPx by rememberUpdatedState(itemWidthPx)
+    val latestViewportSize by rememberUpdatedState(viewportSize)
+
+    fun toggleWebtoonZoom() {
+        if (zoom > 1f) {
+            zoom = 1f
+            pan = Offset.Zero
+        } else {
+            zoom = WEBTOON_DOUBLE_TAP_ZOOM
+        }
+    }
+
+    // 内容缩放模型:graphicsLayer 只负责平移,缩放由每一项的 zoomStripItem 完成,
+    // 这样视口固定、滚动范围随缩放变化,缩小后仍能滚到全部图片。
+    // 手势:多点触控一律吞掉(双指可朝任意方向平移 + 捏合缩放),单指交给 LazyColumn 滚动。
+    val zoomModifier = if (state.settings.disableScale) Modifier else Modifier
+        .graphicsLayer(
+            translationX = pan.x,
+            translationY = pan.y,
+        )
+        .pointerInput(Unit) {
+            awaitEachGesture {
+                awaitFirstDown(requireUnconsumed = false)
+                var handled = false
+                do {
+                    val event = awaitPointerEvent()
+                    val pressed = event.changes.filter { it.pressed }
+                    if (pressed.size >= 2) {
+                        val centroid = event.calculateCentroid()
+                        val zoomChange = event.calculateZoom()
+                        val panChange = event.calculatePan()
+                        val currentZoom = zoom
+                        val newZoom =
+                            (currentZoom * zoomChange).coerceIn(MIN_WEBTOON_ZOOM, MAX_WEBTOON_ZOOM)
+                        val ratio = if (currentZoom > 0f) newZoom / currentZoom else 1f
+                        if (ratio != 1f) {
+                            handled = true
+                            val width = latestViewportSize.width.coerceAtLeast(1).toFloat()
+                            val height = latestViewportSize.height.coerceAtLeast(1).toFloat()
+                            val center = Offset(width / 2f, height / 2f)
+                            val effectiveCentroid = centroid.takeIf { it.isSpecified } ?: center
+                            pan = pan * ratio + (effectiveCentroid - center) * (1f - ratio)
+                        }
+                        if (newZoom <= 1f) {
+                            pan = Offset.Zero
+                        } else if (panChange != Offset.Zero) {
+                            handled = true
+                            pan = clampZoomPan(
+                                target = pan + panChange,
+                                zoom = newZoom,
+                                itemWidth = latestItemWidthPx,
+                                contentHeight = latestNaturalContentHeight,
+                                viewport = latestViewportSize,
+                            )
+                        }
+                        if (handled) pressed.forEach { it.consume() }
+                        zoom = newZoom
+                    } else if (pressed.size == 1) {
+                        if (handled) break
+                    }
+                } while (pressed.isNotEmpty())
+            }
+        }
+
     LaunchedEffect(listState, state.pages) {
     LaunchedEffect(listState, state.pages) {
         snapshotFlow {
         snapshotFlow {
-            listState.layoutInfo.visibleItemsInfo.firstOrNull { visible ->
-                state.pages.getOrNull(visible.index) is MangaReaderItemUi.Page
+            val visibleItems = listState.layoutInfo.visibleItemsInfo
+            val currentChapterVisible = visibleItems.any { item ->
+                (state.pages.getOrNull(item.index) as? MangaReaderItemUi.Page)?.chapterIndex == state.chapterIndex
+            }
+            val firstPageIndex = visibleItems.firstOrNull { item ->
+                state.pages.getOrNull(item.index) is MangaReaderItemUi.Page
             }?.index
             }?.index
+            if (firstPageIndex == null) null else firstPageIndex to currentChapterVisible
         }
         }
             .distinctUntilChanged()
             .distinctUntilChanged()
-            .collect { index -> index?.let { onIntent(MangaReaderIntent.VisibleItemChanged(it)) } }
+            .collect { entry ->
+                entry?.let { (index, stillVisible) ->
+                    onIntent(MangaReaderIntent.VisibleItemChanged(index, stillVisible))
+                }
+            }
     }
     }
     LaunchedEffect(state.scrollRequest?.id) {
     LaunchedEffect(state.scrollRequest?.id) {
         state.scrollRequest?.let {
         state.scrollRequest?.let {
@@ -249,8 +402,11 @@ private fun WebtoonMangaList(
         state.settings.autoReadSpeed,
         state.settings.autoReadSpeed,
         state.menuVisible,
         state.menuVisible,
         state.activeSheet,
         state.activeSheet,
+        state.settingsCategory,
     ) {
     ) {
-        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null) return@LaunchedEffect
+        if (!state.autoReadEnabled || state.menuVisible || state.activeSheet != null ||
+            state.settingsCategory != null
+        ) return@LaunchedEffect
         val distance = state.settings.autoReadSpeed.coerceAtLeast(1)
         val distance = state.settings.autoReadSpeed.coerceAtLeast(1)
         val duration = ceil(16f / distance * 10_000f).toInt()
         val duration = ceil(16f / distance * 10_000f).toInt()
         while (true) {
         while (true) {
@@ -264,10 +420,9 @@ private fun WebtoonMangaList(
             }
             }
         }
         }
     }
     }
-    val fraction = (1f - state.settings.sidePaddingPercent.coerceIn(0, 45) * 2f / 100f)
     LazyColumn(
     LazyColumn(
         state = listState,
         state = listState,
-        modifier = Modifier.fillMaxSize(),
+        modifier = Modifier.fillMaxSize().then(zoomModifier),
         horizontalAlignment = Alignment.CenterHorizontally,
         horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = if (state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP) {
         verticalArrangement = if (state.settings.scrollMode == MangaScrollMode.WEBTOON_WITH_GAP) {
             Arrangement.spacedBy(8.dp)
             Arrangement.spacedBy(8.dp)
@@ -283,8 +438,16 @@ private fun WebtoonMangaList(
                 settings = state.settings,
                 settings = state.settings,
                 onIntent = onIntent,
                 onIntent = onIntent,
                 imageLoader = imageLoader,
                 imageLoader = imageLoader,
-                modifier = Modifier.fillMaxWidth(fraction),
+                modifier = Modifier
+                    .fillMaxWidth(fraction)
+                    .zoomStripItem(zoom)
+                    .graphicsLayer(scaleX = zoom, scaleY = zoom),
                 paged = false,
                 paged = false,
+                onDoubleTap = if (state.settings.disableScale) {
+                    null
+                } else {
+                    { toggleWebtoonZoom() }
+                },
             )
             )
         }
         }
     }
     }
@@ -300,9 +463,14 @@ private fun HorizontalMangaPager(
         initialPage = state.currentItemIndex,
         initialPage = state.currentItemIndex,
         pageCount = { state.pages.size.coerceAtLeast(1) },
         pageCount = { state.pages.size.coerceAtLeast(1) },
     )
     )
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.currentPage }.distinctUntilChanged()
-            .collect { onIntent(MangaReaderIntent.VisibleItemChanged(it)) }
+    LaunchedEffect(pagerState, state.pages) {
+        snapshotFlow {
+            val page = state.pages.getOrNull(pagerState.currentPage) as? MangaReaderItemUi.Page
+            pagerState.currentPage to (page?.chapterIndex == state.chapterIndex)
+        }.distinctUntilChanged()
+            .collect { (page, stillVisible) ->
+                onIntent(MangaReaderIntent.VisibleItemChanged(page, stillVisible))
+            }
     }
     }
     LaunchedEffect(state.scrollRequest?.id) {
     LaunchedEffect(state.scrollRequest?.id) {
         state.scrollRequest?.let {
         state.scrollRequest?.let {
@@ -331,9 +499,14 @@ private fun VerticalMangaPager(
         initialPage = state.currentItemIndex,
         initialPage = state.currentItemIndex,
         pageCount = { state.pages.size.coerceAtLeast(1) },
         pageCount = { state.pages.size.coerceAtLeast(1) },
     )
     )
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.currentPage }.distinctUntilChanged()
-            .collect { onIntent(MangaReaderIntent.VisibleItemChanged(it)) }
+    LaunchedEffect(pagerState, state.pages) {
+        snapshotFlow {
+            val page = state.pages.getOrNull(pagerState.currentPage) as? MangaReaderItemUi.Page
+            pagerState.currentPage to (page?.chapterIndex == state.chapterIndex)
+        }.distinctUntilChanged()
+            .collect { (page, stillVisible) ->
+                onIntent(MangaReaderIntent.VisibleItemChanged(page, stillVisible))
+            }
     }
     }
     LaunchedEffect(state.scrollRequest?.id) {
     LaunchedEffect(state.scrollRequest?.id) {
         state.scrollRequest?.let {
         state.scrollRequest?.let {
@@ -356,9 +529,18 @@ private fun MangaReaderItem(
     imageLoader: ImageLoader,
     imageLoader: ImageLoader,
     modifier: Modifier,
     modifier: Modifier,
     paged: Boolean,
     paged: Boolean,
+    onDoubleTap: (() -> Unit)? = null,
 ) {
 ) {
     when (item) {
     when (item) {
-        is MangaReaderItemUi.Page -> MangaPageImage(item, settings, onIntent, imageLoader, modifier, paged)
+        is MangaReaderItemUi.Page -> MangaPageImage(
+            page = item,
+            settings = settings,
+            onIntent = onIntent,
+            imageLoader = imageLoader,
+            modifier = modifier,
+            paged = paged,
+            onDoubleTap = onDoubleTap,
+        )
         is MangaReaderItemUi.ChapterEdge -> Box(
         is MangaReaderItemUi.ChapterEdge -> Box(
             modifier = modifier.then(if (paged) Modifier.fillMaxHeight() else Modifier.height(96.dp)),
             modifier = modifier.then(if (paged) Modifier.fillMaxHeight() else Modifier.height(96.dp)),
             contentAlignment = Alignment.Center,
             contentAlignment = Alignment.Center,
@@ -374,6 +556,7 @@ private fun MangaPageImage(
     imageLoader: ImageLoader,
     imageLoader: ImageLoader,
     modifier: Modifier,
     modifier: Modifier,
     paged: Boolean,
     paged: Boolean,
+    onDoubleTap: (() -> Unit)? = null,
 ) {
 ) {
     var scale by remember(page.key) { mutableFloatStateOf(1f) }
     var scale by remember(page.key) { mutableFloatStateOf(1f) }
     var offset by remember(page.key) { mutableStateOf(Offset.Zero) }
     var offset by remember(page.key) { mutableStateOf(Offset.Zero) }
@@ -384,8 +567,8 @@ private fun MangaPageImage(
     val context = LocalContext.current
     val context = LocalContext.current
     val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
     val fallbackHeight = LocalConfiguration.current.screenHeightDp.dp
     val transformState = rememberTransformableState { centroid, zoomChange, panChange, _ ->
     val transformState = rememberTransformableState { centroid, zoomChange, panChange, _ ->
-        val newScale = (scale * zoomChange).coerceIn(1f, 5f)
-        if (newScale == 1f) {
+        val newScale = (scale * zoomChange).coerceIn(MIN_PAGE_ZOOM, MAX_PAGE_ZOOM)
+        if (newScale <= 1f) {
             offset = Offset.Zero
             offset = Offset.Zero
         } else {
         } else {
             val maxX = imageSize.width * (newScale - 1f) / 2f
             val maxX = imageSize.width * (newScale - 1f) / 2f
@@ -401,7 +584,8 @@ private fun MangaPageImage(
         }
         }
         scale = newScale
         scale = newScale
     }
     }
-    val transformModifier = if (settings.disableScale) Modifier else Modifier
+    // 翻页模式单页缩放;条漫模式由 WebtoonMangaList 整体缩放,不在此处理单张图
+    val transformModifier = if (paged && !settings.disableScale) Modifier
         .clipToBounds()
         .clipToBounds()
         .graphicsLayer(scaleX = scale, scaleY = scale, translationX = offset.x, translationY = offset.y)
         .graphicsLayer(scaleX = scale, scaleY = scale, translationX = offset.x, translationY = offset.y)
         .transformable(
         .transformable(
@@ -409,6 +593,7 @@ private fun MangaPageImage(
             canPan = { scale > 1f },
             canPan = { scale > 1f },
             lockRotationOnZoomPan = true,
             lockRotationOnZoomPan = true,
         )
         )
+    else Modifier
 
 
     val webtoonSizeModifier = if (paged) Modifier else {
     val webtoonSizeModifier = if (paged) Modifier else {
         aspectRatios[page.key]?.takeIf { it > 0f }?.let { Modifier.aspectRatio(it) }
         aspectRatios[page.key]?.takeIf { it > 0f }?.let { Modifier.aspectRatio(it) }
@@ -447,8 +632,12 @@ private fun MangaPageImage(
                         )
                         )
                     },
                     },
                     onDoubleTap = if (settings.disableScale) null else { _ ->
                     onDoubleTap = if (settings.disableScale) null else { _ ->
-                        scale = if (scale > 1f) 1f else 2.5f
-                        if (scale == 1f) offset = Offset.Zero
+                        if (onDoubleTap != null) {
+                            onDoubleTap()
+                        } else {
+                            scale = if (scale > 1f) 1f else 2.5f
+                            if (scale == 1f) offset = Offset.Zero
+                        }
                     },
                     },
                     onLongPress = if (settings.longPressEnabled) { _ ->
                     onLongPress = if (settings.longPressEnabled) { _ ->
                         onIntent(MangaReaderIntent.LongPressPage(page.imageUrl))
                         onIntent(MangaReaderIntent.LongPressPage(page.imageUrl))

+ 20 - 216
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderSheets.kt

@@ -2,49 +2,29 @@ package io.legado.app.ui.book.manga
 
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.ui.book.manga.config.MangaScrollMode
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.card.SettingCard
 import io.legado.app.ui.widget.components.card.SettingCard
-import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
-import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
-import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
-import kotlin.math.roundToInt
 
 
 @Composable
 @Composable
-internal fun MangaReaderSettingsSheet(
-    sheet: MangaReaderSheet,
+internal fun MangaReaderSourceActionsSheet(
     state: MangaReaderUiState,
     state: MangaReaderUiState,
     onIntent: (MangaReaderIntent) -> Unit,
     onIntent: (MangaReaderIntent) -> Unit,
 ) {
 ) {
-    val settings = state.settings
     AppModalBottomSheet(
     AppModalBottomSheet(
         show = true,
         show = true,
         onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
         onDismissRequest = { onIntent(MangaReaderIntent.DismissSheet) },
-        title = when (sheet) {
-            MangaReaderSheet.Catalog -> stringResource(R.string.chapter_list)
-            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)
-        },
+        title = stringResource(R.string.manga_reader_source_actions),
     ) {
     ) {
         Column(
         Column(
             Modifier
             Modifier
@@ -56,203 +36,27 @@ internal fun MangaReaderSettingsSheet(
                 modifier = Modifier.fillMaxWidth(),
                 modifier = Modifier.fillMaxWidth(),
                 cornerRadius = 16.dp,
                 cornerRadius = 16.dp,
             ) {
             ) {
-            when (sheet) {
-                MangaReaderSheet.Catalog -> Unit
-                MangaReaderSheet.Reader -> {
-                    DropdownListSettingItem(
-                        title = stringResource(R.string.read_type),
-                        selectedValue = settings.scrollMode.toString(),
-                        displayEntries = arrayOf(
-                            stringResource(R.string.webtoon),
-                            stringResource(R.string.manga_reader_webtoon_gap),
-                            stringResource(R.string.manga_reader_left_to_right),
-                            stringResource(R.string.manga_reader_right_to_left),
-                            stringResource(R.string.manga_reader_top_to_bottom),
-                        ),
-                        entryValues = arrayOf(
-                            MangaScrollMode.WEBTOON,
-                            MangaScrollMode.WEBTOON_WITH_GAP,
-                            MangaScrollMode.PAGE_LEFT_TO_RIGHT,
-                            MangaScrollMode.PAGE_RIGHT_TO_LEFT,
-                            MangaScrollMode.PAGE_TOP_TO_BOTTOM,
-                        ).map(Int::toString).toTypedArray(),
-                        onValueChange = {
-                            onIntent(
-                                MangaReaderIntent.UpdateSetting(
-                                    MangaReaderSettingKey.SCROLL_MODE,
-                                    it.toInt(),
-                                )
-                            )
-                        },
-                    )
-                    SettingSlider(stringResource(R.string.manga_reader_side_padding), settings.sidePaddingPercent, 0..45) {
-                        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SIDE_PADDING, it))
-                    }
-                    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) }
-                    DropdownListSettingItem(
-                        title = stringResource(R.string.manga_reader_footer_alignment),
-                        selectedValue = settings.footerAlignment.toString(),
-                        displayEntries = arrayOf(
-                            stringResource(R.string.manga_reader_left_align),
-                            stringResource(R.string.manga_reader_center_align),
-                        ),
-                        entryValues = arrayOf("0", "1"),
-                        onValueChange = {
-                            updateInt(onIntent, MangaReaderSettingKey.FOOTER_ALIGNMENT, it.toInt())
-                        },
-                    )
-                }
-                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 -> {
-                    ClickableSettingItem(
-                        title = stringResource(R.string.login),
-                        onClick = { onIntent(MangaReaderIntent.OpenSourceLogin) },
-                    )
-                    ClickableSettingItem(
-                        title = stringResource(R.string.manga_reader_buy_chapter),
-                        onClick = { onIntent(MangaReaderIntent.RequestPayCurrentChapter) },
-                    )
-                    ClickableSettingItem(
-                        title = stringResource(R.string.edit_source),
-                        onClick = { onIntent(MangaReaderIntent.OpenSourceEdit) },
-                    )
-                    ClickableSettingItem(
-                        title = stringResource(R.string.disable_source),
-                        onClick = {
+                ClickableSettingItem(
+                    title = stringResource(R.string.login),
+                    onClick = { onIntent(MangaReaderIntent.OpenSourceLogin) },
+                )
+                ClickableSettingItem(
+                    title = stringResource(R.string.manga_reader_buy_chapter),
+                    onClick = { onIntent(MangaReaderIntent.RequestPayCurrentChapter) },
+                )
+                ClickableSettingItem(
+                    title = stringResource(R.string.edit_source),
+                    onClick = { onIntent(MangaReaderIntent.OpenSourceEdit) },
+                )
+                ClickableSettingItem(
+                    title = stringResource(R.string.disable_source),
+                    onClick = {
                         onIntent(MangaReaderIntent.DisableCurrentSource)
                         onIntent(MangaReaderIntent.DisableCurrentSource)
                         onIntent(MangaReaderIntent.DismissSheet)
                         onIntent(MangaReaderIntent.DismissSheet)
-                        },
-                    )
-                }
-            }
+                    },
+                )
             }
             }
             Spacer(Modifier.height(24.dp))
             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) {
-    SwitchSettingItem(
-        title = label,
-        checked = checked,
-        onCheckedChange = onCheckedChange,
-    )
-}
-
-@Composable
-private fun SettingSlider(label: String, value: Int, range: IntRange, onValueChange: (Int) -> Unit) {
-    SliderSettingItem(
-        title = label,
-        value = value.toFloat(),
-        defaultValue = range.first.toFloat(),
-        valueRange = range.first.toFloat()..range.last.toFloat(),
-        valueLabel = { it.toInt().toString() },
-        onValueChange = { onValueChange(it.toInt()) },
-    )
-}

+ 41 - 22
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -5,35 +5,33 @@ import androidx.compose.ui.graphics.toArgb
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.data.repository.MangaReaderSessionRepository
-import io.legado.app.data.repository.MangaReaderLaunchRequest
+import io.legado.app.data.model.MangaFooterConfig
 import io.legado.app.data.repository.MangaPaymentResult
 import io.legado.app.data.repository.MangaPaymentResult
+import io.legado.app.data.repository.MangaReaderLaunchRequest
 import io.legado.app.data.repository.MangaReaderSessionEvent
 import io.legado.app.data.repository.MangaReaderSessionEvent
+import io.legado.app.data.repository.MangaReaderSessionRepository
 import io.legado.app.data.repository.MangaReaderSessionState
 import io.legado.app.data.repository.MangaReaderSessionState
-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.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.model.settings.MangaSettings
 import io.legado.app.domain.model.settings.MangaSettings
+import io.legado.app.model.manga.MangaPage
+import io.legado.app.model.manga.ReaderLoading
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.ui.book.manga.config.MangaColorFilterConfig
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
 
 
 class MangaReaderViewModel(
 class MangaReaderViewModel(
     private val mangaSettingsGateway: MangaSettingsGateway,
     private val mangaSettingsGateway: MangaSettingsGateway,
@@ -151,6 +149,7 @@ class MangaReaderViewModel(
                     _uiState.value.activeSheet != null -> {
                     _uiState.value.activeSheet != null -> {
                         _uiState.update { it.copy(activeSheet = null) }
                         _uiState.update { it.copy(activeSheet = null) }
                     }
                     }
+                    _uiState.value.settingsCategory != null -> closeSettings()
                     _uiState.value.menuVisible -> setMenuVisible(false)
                     _uiState.value.menuVisible -> setMenuVisible(false)
                     sessionRepository.hasBook() && !sessionRepository.inBookshelf() &&
                     sessionRepository.hasBook() && !sessionRepository.inBookshelf() &&
                             _uiState.value.confirmAddToShelf -> {
                             _uiState.value.confirmAddToShelf -> {
@@ -193,23 +192,23 @@ class MangaReaderViewModel(
                     sessionRepository.refreshCurrentChapter()
                     sessionRepository.refreshCurrentChapter()
                 }
                 }
             }
             }
-            MangaReaderIntent.OpenReaderSettings,
-            MangaReaderIntent.OpenPreDownloadSettings -> showSheet(MangaReaderSheet.Reader)
-            MangaReaderIntent.OpenAutoReadSettings -> showSheet(MangaReaderSheet.AutoRead)
-            MangaReaderIntent.OpenColorFilter -> showSheet(MangaReaderSheet.ColorFilter)
-            MangaReaderIntent.OpenClickSettings -> showSheet(MangaReaderSheet.ClickActions)
+            is MangaReaderIntent.OpenSettings -> openSettings(intent.category)
+            MangaReaderIntent.CloseSettings -> closeSettings()
             MangaReaderIntent.OpenSourceActions -> showSheet(MangaReaderSheet.SourceActions)
             MangaReaderIntent.OpenSourceActions -> showSheet(MangaReaderSheet.SourceActions)
             MangaReaderIntent.ToggleAutoRead -> _uiState.update {
             MangaReaderIntent.ToggleAutoRead -> _uiState.update {
                 it.copy(autoReadEnabled = !it.autoReadEnabled, menuVisible = false)
                 it.copy(autoReadEnabled = !it.autoReadEnabled, menuVisible = false)
             }
             }
             MangaReaderIntent.DismissSheet -> _uiState.update {
             MangaReaderIntent.DismissSheet -> _uiState.update {
-                it.copy(activeSheet = null, changeSourceBook = null)
+                it.copy(activeSheet = null, changeSourceBook = null, settingsCategory = null)
             }
             }
             is MangaReaderIntent.UpdateSetting -> updateSetting(intent.key, intent.value)
             is MangaReaderIntent.UpdateSetting -> updateSetting(intent.key, intent.value)
             is MangaReaderIntent.UpdateClickAction -> updateClickAction(intent.index, intent.action)
             is MangaReaderIntent.UpdateClickAction -> updateClickAction(intent.index, intent.action)
             is MangaReaderIntent.PageStep -> requestPageStep(intent.direction)
             is MangaReaderIntent.PageStep -> requestPageStep(intent.direction)
             is MangaReaderIntent.SeekToPage -> seekToPage(intent.pageIndex)
             is MangaReaderIntent.SeekToPage -> seekToPage(intent.pageIndex)
-            is MangaReaderIntent.VisibleItemChanged -> updateVisibleItem(intent.itemIndex)
+            is MangaReaderIntent.VisibleItemChanged -> updateVisibleItem(
+                intent.itemIndex,
+                intent.currentChapterVisible,
+            )
             is MangaReaderIntent.LongPressPage -> {
             is MangaReaderIntent.LongPressPage -> {
                 if (_uiState.value.settings.longPressEnabled) {
                 if (_uiState.value.settings.longPressEnabled) {
                     saveImage(intent.imageUrl)
                     saveImage(intent.imageUrl)
@@ -367,7 +366,7 @@ class MangaReaderViewModel(
                     } else old.scrollRequest,
                     } else old.scrollRequest,
                 )
                 )
             }
             }
-            if (shouldPosition) updateVisibleItem(safePosition)
+            if (shouldPosition) updateVisibleItem(safePosition, currentChapterVisible = true)
         }
         }
     }
     }
 
 
@@ -431,10 +430,24 @@ class MangaReaderViewModel(
     }
     }
 
 
     private fun setMenuVisible(visible: Boolean) {
     private fun setMenuVisible(visible: Boolean) {
-        _uiState.update { it.copy(menuVisible = visible) }
+        _uiState.update {
+            it.copy(
+                menuVisible = visible,
+                settingsCategory = if (visible) it.settingsCategory else null,
+            )
+        }
         _effects.tryEmit(MangaReaderEffect.SetSystemBarsVisible(visible))
         _effects.tryEmit(MangaReaderEffect.SetSystemBarsVisible(visible))
     }
     }
 
 
+    private fun openSettings(category: MangaReaderSettingsCategory) {
+        setMenuVisible(true)
+        _uiState.update { it.copy(settingsCategory = category, activeSheet = null) }
+    }
+
+    private fun closeSettings() {
+        _uiState.update { it.copy(settingsCategory = null) }
+    }
+
     private fun emitAndHide(effect: MangaReaderEffect) {
     private fun emitAndHide(effect: MangaReaderEffect) {
         setMenuVisible(false)
         setMenuVisible(false)
         _effects.tryEmit(effect)
         _effects.tryEmit(effect)
@@ -676,19 +689,25 @@ class MangaReaderViewModel(
         }
         }
     }
     }
 
 
-    private fun updateVisibleItem(itemIndex: Int) {
+    private fun updateVisibleItem(itemIndex: Int, currentChapterVisible: Boolean) {
         val state = _uiState.value
         val state = _uiState.value
         if (state.isLoading) return
         if (state.isLoading) return
         val item = state.pages.getOrNull(itemIndex) as? MangaReaderItemUi.Page ?: return
         val item = state.pages.getOrNull(itemIndex) as? MangaReaderItemUi.Page ?: return
+        when (mangaChapterSwitchDecision(
+            currentChapterIndex = sessionRepository.currentChapterIndex(),
+            visibleChapterIndex = item.chapterIndex,
+            currentChapterVisible = currentChapterVisible,
+        )) {
+            MangaChapterSwitch.NEXT -> sessionRepository.moveToNextChapter()
+            MangaChapterSwitch.PREVIOUS -> sessionRepository.moveToPreviousChapter()
+            MangaChapterSwitch.NONE -> Unit
+        }
         sessionRepository.setVisiblePage(item.chapterIndex, item.pageIndex)
         sessionRepository.setVisiblePage(item.chapterIndex, item.pageIndex)
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
                 currentItemIndex = itemIndex,
                 currentItemIndex = itemIndex,
                 currentPage = item.pageIndex,
                 currentPage = item.pageIndex,
                 pageCount = item.pageCount,
                 pageCount = item.pageCount,
-                chapterIndex = item.chapterIndex,
-                chapterCount = item.chapterCount,
-                chapterName = item.chapterName,
             )
             )
         }
         }
     }
     }

+ 546 - 0
app/src/main/java/io/legado/app/ui/book/manga/MangaSettingsPanel.kt

@@ -0,0 +1,546 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.book.manga.config.MangaScrollMode
+import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
+import io.legado.app.ui.widget.components.reader.ReaderMenuIconButton
+import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
+import io.legado.app.ui.widget.components.tabRow.CardTabRow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlin.math.roundToInt
+
+private val MangaSettingsPanelContentMaxHeight = 380.dp
+
+/** 底部栏设置面板顶部的分类 tab(自动阅读不在此列,长按菜单按钮进入)。 */
+private val MangaSettingsTabs = listOf(
+    MangaReaderSettingsCategory.READER,
+    MangaReaderSettingsCategory.FOOTER,
+    MangaReaderSettingsCategory.FILTER,
+    MangaReaderSettingsCategory.CLICK_ACTIONS,
+)
+
+private val MangaReaderSettingsCategory.titleRes: Int
+    get() = when (this) {
+        MangaReaderSettingsCategory.READER -> R.string.general
+        MangaReaderSettingsCategory.FOOTER -> R.string.footer
+        MangaReaderSettingsCategory.FILTER -> R.string.manga_reader_filter_short
+        MangaReaderSettingsCategory.CLICK_ACTIONS -> R.string.manga_reader_click_area_short
+        MangaReaderSettingsCategory.AUTO_READ -> R.string.manga_reader_auto_read
+    }
+
+private enum class MangaColorPickerTarget { BACKGROUND, FILTER }
+
+/**
+ * 漫画阅读设置的底部栏面板:顶部分类 tab([CardTabRow]),内容区用
+ * [HorizontalPager] 左右滑动切换;自动阅读分类不在 tab 里,由长按菜单按钮进入。
+ */
+@Composable
+internal fun MangaSettingsPanel(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    val categories = MangaReaderSettingsCategory.entries
+    val current = state.settingsCategory ?: MangaReaderSettingsCategory.READER
+    val pagerState = rememberPagerState(
+        initialPage = current.ordinal,
+        pageCount = { categories.size },
+    )
+    var selectedTab by remember { mutableIntStateOf(current.ordinal) }
+    var colorPickerTarget by remember { mutableStateOf<MangaColorPickerTarget?>(null) }
+    val pageHeights = remember { mutableStateMapOf<Int, Int>() }
+    val animatedHeight by rememberPagerAnimatedHeight(pagerState, pageHeights)
+
+    LaunchedEffect(pagerState) {
+        snapshotFlow { pagerState.currentPage }
+            .distinctUntilChanged()
+            .collect { selectedTab = it }
+    }
+    LaunchedEffect(current) {
+        if (pagerState.currentPage != current.ordinal) {
+            pagerState.scrollToPage(current.ordinal)
+        }
+    }
+
+    Column(modifier = Modifier.fillMaxWidth()) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 8.dp, vertical = 4.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            ReaderMenuIconButton(
+                icon = AppIcons.Back,
+                description = stringResource(R.string.back),
+                onClick = { onIntent(MangaReaderIntent.CloseSettings) },
+            )
+            CardTabRow(
+                tabTitles = MangaSettingsTabs.map { stringResource(it.titleRes) },
+                selectedTabIndex = MangaSettingsTabs.indexOf(categories[selectedTab]),
+                onTabSelected = { index ->
+                    onIntent(MangaReaderIntent.OpenSettings(MangaSettingsTabs[index]))
+                },
+                modifier = Modifier.weight(1f),
+            )
+        }
+        Spacer(Modifier.height(8.dp))
+        HorizontalPager(
+            state = pagerState,
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = MangaSettingsPanelContentMaxHeight)
+                .pagerHeight(animatedHeight),
+        ) { page ->
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .onSizeChanged { size -> pageHeights[page] = size.height },
+            ) {
+                Column(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .verticalScroll(rememberScrollState()),
+                ) {
+                    when (categories[page]) {
+                        MangaReaderSettingsCategory.READER -> ReaderSettingsContent(
+                            state.settings,
+                            onIntent
+                        )
+
+                        MangaReaderSettingsCategory.FOOTER -> FooterSettingsContent(
+                            state.settings,
+                            onIntent
+                        )
+
+                        MangaReaderSettingsCategory.FILTER -> FilterSettingsContent(
+                            settings = state.settings,
+                            onPickBackground = {
+                                colorPickerTarget = MangaColorPickerTarget.BACKGROUND
+                            },
+                            onPickFilter = { colorPickerTarget = MangaColorPickerTarget.FILTER },
+                            onIntent = onIntent,
+                        )
+
+                        MangaReaderSettingsCategory.CLICK_ACTIONS -> ClickActionsSettingsContent(
+                            state.settings,
+                            onIntent
+                        )
+
+                        MangaReaderSettingsCategory.AUTO_READ -> AutoReadSettingsContent(
+                            state,
+                            onIntent
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    colorPickerTarget?.let { target ->
+        ColorPickerSheet(
+            show = true,
+            initialColor = when (target) {
+                MangaColorPickerTarget.BACKGROUND -> state.settings.backgroundColor.toArgb()
+                MangaColorPickerTarget.FILTER -> Color(
+                    red = 255 - state.settings.filterRed,
+                    green = 255 - state.settings.filterGreen,
+                    blue = 255 - state.settings.filterBlue,
+                ).toArgb()
+            },
+            onDismissRequest = { colorPickerTarget = null },
+            onColorSelected = { argb ->
+                val picked = Color(argb)
+                val red = (picked.red * 255).roundToInt()
+                val green = (picked.green * 255).roundToInt()
+                val blue = (picked.blue * 255).roundToInt()
+                when (target) {
+                    MangaColorPickerTarget.BACKGROUND -> {
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.BACKGROUND_RED,
+                                red
+                            )
+                        )
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.BACKGROUND_GREEN,
+                                green
+                            )
+                        )
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.BACKGROUND_BLUE,
+                                blue
+                            )
+                        )
+                    }
+
+                    MangaColorPickerTarget.FILTER -> {
+                        // 存储的是「移除量」:所选颜色即要保留的通道,取反映射
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.FILTER_RED,
+                                255 - red
+                            )
+                        )
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.FILTER_GREEN,
+                                255 - green
+                            )
+                        )
+                        onIntent(
+                            MangaReaderIntent.UpdateSetting(
+                                MangaReaderSettingKey.FILTER_BLUE,
+                                255 - blue
+                            )
+                        )
+                    }
+                }
+                colorPickerTarget = null
+            },
+        )
+    }
+}
+
+@Composable
+private fun ReaderSettingsContent(
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    TinyDropdownSettingItem(
+        title = stringResource(R.string.read_type),
+        selectedValue = settings.scrollMode.toString(),
+        displayEntries = arrayOf(
+            stringResource(R.string.webtoon),
+            stringResource(R.string.manga_reader_webtoon_gap),
+            stringResource(R.string.manga_reader_left_to_right),
+            stringResource(R.string.manga_reader_right_to_left),
+            stringResource(R.string.manga_reader_top_to_bottom),
+        ),
+        entryValues = arrayOf(
+            MangaScrollMode.WEBTOON,
+            MangaScrollMode.WEBTOON_WITH_GAP,
+            MangaScrollMode.PAGE_LEFT_TO_RIGHT,
+            MangaScrollMode.PAGE_RIGHT_TO_LEFT,
+            MangaScrollMode.PAGE_TOP_TO_BOTTOM,
+        ).map(Int::toString).toTypedArray(),
+        onValueChange = {
+            onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SCROLL_MODE, it.toInt()))
+        },
+    )
+    SettingSlider(
+        stringResource(R.string.manga_reader_side_padding),
+        settings.sidePaddingPercent,
+        0..45
+    ) {
+        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.SIDE_PADDING, it))
+    }
+    SettingSlider(
+        stringResource(R.string.manga_reader_preload_pages),
+        settings.preDownloadCount,
+        0..30
+    ) {
+        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.PRE_DOWNLOAD, 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
+            )
+        )
+    }
+}
+
+@Composable
+private fun FooterSettingsContent(
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    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) }
+    TinyDropdownSettingItem(
+        title = stringResource(R.string.manga_reader_footer_alignment),
+        selectedValue = settings.footerAlignment.toString(),
+        displayEntries = arrayOf(
+            stringResource(R.string.manga_reader_left_align),
+            stringResource(R.string.manga_reader_center_align),
+        ),
+        entryValues = arrayOf("0", "1"),
+        onValueChange = {
+            updateInt(onIntent, MangaReaderSettingKey.FOOTER_ALIGNMENT, it.toInt())
+        },
+    )
+}
+
+@Composable
+private fun FilterSettingsContent(
+    settings: MangaReaderSettings,
+    onPickBackground: () -> Unit,
+    onPickFilter: () -> Unit,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    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))
+    }
+    TinyColorSettingItem(
+        title = stringResource(R.string.background_color),
+        colorValue = settings.backgroundColor.toArgb(),
+        onClick = onPickBackground,
+    )
+    TinyColorSettingItem(
+        title = stringResource(R.string.manga_reader_display_filter),
+        colorValue = Color(
+            red = 255 - settings.filterRed,
+            green = 255 - settings.filterGreen,
+            blue = 255 - settings.filterBlue,
+        ).toArgb(),
+        onClick = onPickFilter,
+    )
+    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)
+        }
+    }
+}
+
+@Composable
+private fun AutoReadSettingsContent(
+    state: MangaReaderUiState,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    SettingSwitch(stringResource(R.string.manga_reader_enable_auto_read), state.autoReadEnabled) {
+        onIntent(MangaReaderIntent.ToggleAutoRead)
+    }
+    SettingSlider(
+        stringResource(R.string.manga_reader_auto_speed),
+        state.settings.autoReadSpeed,
+        1..15
+    ) {
+        onIntent(MangaReaderIntent.UpdateSetting(MangaReaderSettingKey.AUTO_READ_SPEED, it))
+    }
+}
+
+@Composable
+private fun ClickActionsSettingsContent(
+    settings: MangaReaderSettings,
+    onIntent: (MangaReaderIntent) -> Unit,
+) {
+    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
+    )
+}
+
+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) {
+    TinySwitchSettingItem(
+        title = label,
+        checked = checked,
+        onCheckedChange = onCheckedChange,
+    )
+}
+
+@Composable
+private fun SettingSlider(
+    label: String,
+    value: Int,
+    range: IntRange,
+    onValueChange: (Int) -> Unit
+) {
+    TinySliderSettingItem(
+        title = label,
+        value = value.toFloat().coerceIn(range.first.toFloat(), range.last.toFloat()),
+        valueRange = range.first.toFloat()..range.last.toFloat(),
+        onValueChange = { onValueChange(it.toInt()) },
+    )
+}

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

@@ -6,6 +6,7 @@ import androidx.compose.animation.fadeOut
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideInVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.slideOutVertically
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
 import androidx.compose.material3.IconButton
 import androidx.compose.material3.IconButton
@@ -26,7 +28,9 @@ import androidx.compose.runtime.Stable
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppSlider
 import io.legado.app.ui.widget.components.AppSlider
@@ -107,6 +111,7 @@ fun DefaultReaderMenuBottomSurface(content: @Composable () -> Unit) {
 data class ReaderMenuAction(
 data class ReaderMenuAction(
     val icon: ImageVector,
     val icon: ImageVector,
     val description: String,
     val description: String,
+    val onLongClick: (() -> Unit)? = null,
     val onClick: () -> Unit,
     val onClick: () -> Unit,
 )
 )
 
 
@@ -125,6 +130,7 @@ fun ReaderMenuToolRow(
                 icon = action.icon,
                 icon = action.icon,
                 description = action.description,
                 description = action.description,
                 onClick = action.onClick,
                 onClick = action.onClick,
+                onLongClick = action.onLongClick,
             )
             )
         }
         }
         repeat((columns - actions.size).coerceAtLeast(0)) {
         repeat((columns - actions.size).coerceAtLeast(0)) {
@@ -138,15 +144,37 @@ fun ReaderMenuIconButton(
     icon: ImageVector,
     icon: ImageVector,
     description: String,
     description: String,
     modifier: Modifier = Modifier,
     modifier: Modifier = Modifier,
+    onLongClick: (() -> Unit)? = null,
     onClick: () -> Unit,
     onClick: () -> Unit,
 ) {
 ) {
-    IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
-        Icon(
-            imageVector = icon,
-            contentDescription = description,
-            tint = LegadoTheme.colorScheme.onSurface,
-            modifier = Modifier.size(22.dp),
-        )
+    if (onLongClick == null) {
+        IconButton(onClick = onClick, modifier = modifier.size(48.dp)) {
+            Icon(
+                imageVector = icon,
+                contentDescription = description,
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(22.dp),
+            )
+        }
+    } else {
+        Box(
+            modifier = modifier
+                .size(48.dp)
+                .clip(CircleShape)
+                .combinedClickable(
+                    onClick = onClick,
+                    onLongClick = onLongClick,
+                    role = Role.Button,
+                ),
+            contentAlignment = Alignment.Center,
+        ) {
+            Icon(
+                imageVector = icon,
+                contentDescription = description,
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(22.dp),
+            )
+        }
     }
     }
 }
 }
 
 

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

@@ -1,8 +1,10 @@
 package io.legado.app.ui.book.manga
 package io.legado.app.ui.book.manga
 
 
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.IntSize
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNull
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
 import org.junit.Assert.assertTrue
 import org.junit.Assert.assertTrue
 import org.junit.Test
 import org.junit.Test
 
 
@@ -61,4 +63,127 @@ class MangaReaderInteractionTest {
         assertFalse(shouldExposeMangaPages(currentChapterFinished = false))
         assertFalse(shouldExposeMangaPages(currentChapterFinished = false))
         assertTrue(shouldExposeMangaPages(currentChapterFinished = true))
         assertTrue(shouldExposeMangaPages(currentChapterFinished = true))
     }
     }
+
+    @Test
+    fun `chapter switch stays put while current chapter is still visible`() {
+        assertEquals(
+            MangaChapterSwitch.NONE,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 6,
+                currentChapterVisible = true,
+            ),
+        )
+        assertEquals(
+            MangaChapterSwitch.NONE,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 4,
+                currentChapterVisible = true,
+            ),
+        )
+    }
+
+    @Test
+    fun `chapter switch fires only when current chapter fully off-screen`() {
+        assertEquals(
+            MangaChapterSwitch.NEXT,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 6,
+                currentChapterVisible = false,
+            ),
+        )
+        assertEquals(
+            MangaChapterSwitch.PREVIOUS,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 4,
+                currentChapterVisible = false,
+            ),
+        )
+    }
+
+    @Test
+    fun `same chapter never switches regardless of visibility`() {
+        assertEquals(
+            MangaChapterSwitch.NONE,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 5,
+                currentChapterVisible = true,
+            ),
+        )
+        assertEquals(
+            MangaChapterSwitch.NONE,
+            mangaChapterSwitchDecision(
+                currentChapterIndex = 5,
+                visibleChapterIndex = 5,
+                currentChapterVisible = false,
+            ),
+        )
+    }
+
+    @Test
+    fun `zoom pan clamps within zoomed content bounds`() {
+        val viewport = IntSize(500, 800)
+        // zoom 2、item 宽 500:maxX = (500*2-500)/2 = 250;内容高 2000:maxY = 2000*2-800 = 3200
+        assertEquals(
+            Offset(250f, -3200f),
+            clampZoomPan(
+                Offset(9999f, -9999f),
+                zoom = 2f,
+                itemWidth = 500f,
+                contentHeight = 2000f,
+                viewport = viewport
+            ),
+        )
+        assertEquals(
+            Offset(-250f, 0f),
+            clampZoomPan(
+                Offset(-9999f, 9999f),
+                zoom = 2f,
+                itemWidth = 500f,
+                contentHeight = 2000f,
+                viewport = viewport
+            ),
+        )
+        assertEquals(
+            Offset(100f, -500f),
+            clampZoomPan(
+                Offset(100f, -500f),
+                zoom = 2f,
+                itemWidth = 500f,
+                contentHeight = 2000f,
+                viewport = viewport
+            ),
+        )
+    }
+
+    @Test
+    fun `zoom pan keeps content when item narrower than viewport`() {
+        val viewport = IntSize(500, 800)
+        // item 200 宽,放大 2 倍仍只有 400 < 500:不允许横向平移
+        assertEquals(
+            Offset(0f, -500f),
+            clampZoomPan(
+                Offset(9999f, -500f),
+                zoom = 2f,
+                itemWidth = 200f,
+                contentHeight = 2000f,
+                viewport = viewport
+            ),
+        )
+        // 内容高度未知时不允许平移
+        assertEquals(
+            Offset.Zero,
+            clampZoomPan(
+                Offset(100f, -100f),
+                zoom = 2f,
+                itemWidth = 500f,
+                contentHeight = 0f,
+                viewport = viewport
+            ),
+        )
+    }
 }
 }