Browse Source

Merge branch 'main' into feat/ai-auto-group

lyc 3 months ago
parent
commit
f4b49b8d65
91 changed files with 2487 additions and 989 deletions
  1. 1 0
      app/build.gradle.kts
  2. 3 6
      app/src/main/AndroidManifest.xml
  3. 19 6
      app/src/main/java/io/legado/app/App.kt
  4. 1 0
      app/src/main/java/io/legado/app/constant/AppConst.kt
  5. 1 0
      app/src/main/java/io/legado/app/constant/NotificationId.kt
  6. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  7. 101 15
      app/src/main/java/io/legado/app/data/repository/BookSourceCheckRepository.kt
  8. 2 0
      app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt
  9. 6 5
      app/src/main/java/io/legado/app/di/appModule.kt
  10. 32 2
      app/src/main/java/io/legado/app/domain/gateway/BookSourceCheckGateway.kt
  11. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt
  12. 26 0
      app/src/main/java/io/legado/app/help/coil/CoverExtras.kt
  13. 27 23
      app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt
  14. 7 10
      app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt
  15. 1 1
      app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt
  16. 8 12
      app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt
  17. 1 0
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  18. 205 0
      app/src/main/java/io/legado/app/service/BookSourceCheckService.kt
  19. 6 5
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  20. 4 4
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  21. 1 1
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt
  22. 1 1
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt
  23. 1 1
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkScreen.kt
  24. 3 3
      app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarCropDialog.kt
  25. 11 10
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  26. 17 16
      app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt
  27. 11 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  28. 9 10
      app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt
  29. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarFloating.kt
  30. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarTool.kt
  31. 4 9
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  32. 4 4
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  33. 6 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  34. 1 1
      app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt
  35. 23 5
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  36. 2 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt
  37. 3 3
      app/src/main/java/io/legado/app/ui/book/read/sheet/GlobalThemePage.kt
  38. 1 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/TextSelectMenuConfigSheet.kt
  39. 11 2
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  40. 39 3
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  41. 274 24
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  42. 23 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  43. 2 2
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditRoute.kt
  44. 7 0
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt
  45. 62 35
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt
  46. 75 10
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt
  47. 0 319
      app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt
  48. 199 0
      app/src/main/java/io/legado/app/ui/browser/WebViewRouteScreen.kt
  49. 1 0
      app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt
  50. 2 2
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt
  51. 1 2
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt
  52. 149 52
      app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
  53. 1 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt
  54. 4 2
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt
  55. 145 135
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  56. 201 0
      app/src/main/java/io/legado/app/ui/dict/DictHtmlContent.kt
  57. 307 0
      app/src/main/java/io/legado/app/ui/dict/DictHtmlParser.kt
  58. 8 67
      app/src/main/java/io/legado/app/ui/dict/DictSheet.kt
  59. 3 1
      app/src/main/java/io/legado/app/ui/login/SourceLoginSheets.kt
  60. 15 0
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  61. 30 0
      app/src/main/java/io/legado/app/ui/main/MainIntent.kt
  62. 26 6
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  63. 13 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  64. 23 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  65. 3 2
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  66. 11 3
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  67. 9 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt
  68. 2 2
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt
  69. 2 2
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditRoute.kt
  70. 1 1
      app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
  71. 8 5
      app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
  72. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/AppContainerBackground.kt
  73. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
  74. 5 6
      app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt
  75. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/ReorderableConfigList.kt
  76. 26 8
      app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
  77. 20 99
      app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
  78. 2 2
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  79. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverBlurBackdrop.kt
  80. 5 3
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverImageRequest.kt
  81. 10 6
      app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt
  82. 31 6
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  83. 52 1
      app/src/main/java/io/legado/app/ui/widget/components/navigation/AppNavigationBar.kt
  84. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt
  85. 12 0
      app/src/main/res/values-zh-rCN/strings.xml
  86. 12 0
      app/src/main/res/values-zh-rHK/strings.xml
  87. 12 0
      app/src/main/res/values-zh-rTW/strings.xml
  88. 12 0
      app/src/main/res/values/strings.xml
  89. 5 2
      app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt
  90. 61 0
      app/src/test/java/io/legado/app/ui/dict/DictHtmlParserTest.kt
  91. 5 4
      gradle/libs.versions.toml

+ 1 - 0
app/build.gradle.kts

@@ -273,6 +273,7 @@ dependencies {
     implementation(libs.coil.compose)
     implementation(libs.coil.compose)
     implementation(libs.coil.gif)
     implementation(libs.coil.gif)
     implementation(libs.coil.svg)
     implementation(libs.coil.svg)
+    implementation(libs.coil.network.okhttp)
     implementation(libs.accompanist.webview)
     implementation(libs.accompanist.webview)
     implementation(libs.kotlinx.coroutines.android)
     implementation(libs.kotlinx.coroutines.android)
     implementation(libs.androidx.lifecycle.viewmodel.compose)
     implementation(libs.androidx.lifecycle.viewmodel.compose)

+ 3 - 6
app/src/main/AndroidManifest.xml

@@ -276,12 +276,6 @@
             android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:configChanges="locale|layoutDirection|uiMode|screenLayout"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:launchMode="singleTop" />
             android:launchMode="singleTop" />
-        <!-- WebView界面 -->
-        <activity
-            android:name=".ui.browser.WebViewActivity"
-            android:enableOnBackInvokedCallback="true"
-            android:configChanges="locale|layoutDirection|uiMode|keyboardHidden|orientation|screenSize|smallestScreenSize|screenLayout"
-            android:launchMode="standard" />
         <!-- 字典管理 -->
         <!-- 字典管理 -->
         <activity
         <activity
             android:name=".ui.dict.rule.DictRuleActivity"
             android:name=".ui.dict.rule.DictRuleActivity"
@@ -448,6 +442,9 @@
         <service
         <service
             android:name=".service.CacheBookService"
             android:name=".service.CacheBookService"
             android:foregroundServiceType="dataSync" />
             android:foregroundServiceType="dataSync" />
+        <service
+            android:name=".service.BookSourceCheckService"
+            android:foregroundServiceType="dataSync" />
         <service
         <service
             android:name=".service.ExportBookService"
             android:name=".service.ExportBookService"
             android:foregroundServiceType="dataSync" />
             android:foregroundServiceType="dataSync" />

+ 19 - 6
app/src/main/java/io/legado/app/App.kt

@@ -9,8 +9,8 @@ import android.content.pm.ApplicationInfo
 import android.graphics.BitmapFactory
 import android.graphics.BitmapFactory
 import android.os.Build
 import android.os.Build
 import androidx.core.graphics.scale
 import androidx.core.graphics.scale
-import coil.ImageLoader
-import coil.ImageLoaderFactory
+import coil3.ImageLoader
+import coil3.SingletonImageLoader
 import com.github.liuyueyi.quick.transfer.constants.TransType
 import com.github.liuyueyi.quick.transfer.constants.TransType
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColorsOptions
 import com.google.android.material.color.DynamicColorsOptions
@@ -19,6 +19,7 @@ import com.jeremyliao.liveeventbus.logger.DefaultLogger
 import com.script.rhino.ReadOnlyJavaObject
 import com.script.rhino.ReadOnlyJavaObject
 import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.RhinoWrapFactory
 import com.script.rhino.RhinoWrapFactory
+import io.legado.app.constant.AppConst.channelIdBookSourceCheck
 import io.legado.app.constant.AppConst.channelIdDownload
 import io.legado.app.constant.AppConst.channelIdDownload
 import io.legado.app.constant.AppConst.channelIdReadAloud
 import io.legado.app.constant.AppConst.channelIdReadAloud
 import io.legado.app.constant.AppConst.channelIdWeb
 import io.legado.app.constant.AppConst.channelIdWeb
@@ -70,10 +71,10 @@ import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.isDebuggable
 import io.legado.app.utils.isDebuggable
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.chromium.base.ThreadUtils
 import org.chromium.base.ThreadUtils
 import org.koin.android.ext.android.get
 import org.koin.android.ext.android.get
 import org.koin.android.ext.koin.androidContext
 import org.koin.android.ext.koin.androidContext
@@ -85,9 +86,9 @@ import java.net.URL
 import java.util.concurrent.TimeUnit
 import java.util.concurrent.TimeUnit
 import java.util.logging.Level
 import java.util.logging.Level
 
 
-class App : Application(), ImageLoaderFactory {
+class App : Application(), SingletonImageLoader.Factory {
 
 
-    override fun newImageLoader(): ImageLoader {
+    override fun newImageLoader(context: Context): ImageLoader {
         return get()
         return get()
     }
     }
 
 
@@ -300,6 +301,17 @@ class App : Application(), ImageLoaderFactory {
             lockscreenVisibility = Notification.VISIBILITY_PUBLIC
             lockscreenVisibility = Notification.VISIBILITY_PUBLIC
         }
         }
 
 
+        val bookSourceCheckChannel = NotificationChannel(
+            channelIdBookSourceCheck,
+            getString(R.string.check_book_source),
+            NotificationManager.IMPORTANCE_DEFAULT
+        ).apply {
+            enableLights(false)
+            enableVibration(false)
+            setSound(null, null)
+            lockscreenVisibility = Notification.VISIBILITY_PUBLIC
+        }
+
         val webChannel = NotificationChannel(
         val webChannel = NotificationChannel(
             channelIdWeb,
             channelIdWeb,
             getString(R.string.web_service),
             getString(R.string.web_service),
@@ -315,6 +327,7 @@ class App : Application(), ImageLoaderFactory {
         notificationManager.createNotificationChannels(
         notificationManager.createNotificationChannels(
             listOf(
             listOf(
                 downloadChannel,
                 downloadChannel,
+                bookSourceCheckChannel,
                 readAloudChannel,
                 readAloudChannel,
                 webChannel
                 webChannel
             )
             )

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

@@ -17,6 +17,7 @@ object AppConst {
     const val APP_TAG = "Legado"
     const val APP_TAG = "Legado"
 
 
     const val channelIdDownload = "channel_download"
     const val channelIdDownload = "channel_download"
+    const val channelIdBookSourceCheck = "channel_book_source_check"
     const val channelIdReadAloud = "channel_read_aloud"
     const val channelIdReadAloud = "channel_read_aloud"
     const val channelIdWeb = "channel_web"
     const val channelIdWeb = "channel_web"
 
 

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

@@ -12,6 +12,7 @@ object NotificationId {
     const val ExportBookService = 104
     const val ExportBookService = 104
     const val WebService = 105
     const val WebService = 105
     const val DownloadService = 106
     const val DownloadService = 106
+    const val BookSourceCheckService = 107
     const val Download = 10000
     const val Download = 10000
     const val ExportBook = 201
     const val ExportBook = 201
 
 

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

@@ -57,6 +57,7 @@ object PreferKey {
     const val bookshelfLayoutModePortrait = "bookshelfLayoutPortrait"
     const val bookshelfLayoutModePortrait = "bookshelfLayoutPortrait"
     const val bookshelfLayoutModeLandscape = "bookshelf_layout_landscape"
     const val bookshelfLayoutModeLandscape = "bookshelf_layout_landscape"
     const val bookshelfLayoutCompact = "bookshelfLayoutCompact"
     const val bookshelfLayoutCompact = "bookshelfLayoutCompact"
+    const val bookshelfListCoverCenter = "bookshelfListCoverCenter"
     const val bookshelfListIntroBelowContent = "bookshelfListIntroBelowContent"
     const val bookshelfListIntroBelowContent = "bookshelfListIntroBelowContent"
     const val bookshelfShowDivider = "bookshelfShowDivider"
     const val bookshelfShowDivider = "bookshelfShowDivider"
     const val bookshelfGridLayout = "bookshelfGridLayout"
     const val bookshelfGridLayout = "bookshelfGridLayout"

+ 101 - 15
app/src/main/java/io/legado/app/data/repository/BookSourceCheckRepository.kt

@@ -5,7 +5,10 @@ import io.legado.app.constant.BookSourceType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.domain.gateway.BookSourceCheckGateway
 import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.gateway.BookSourceCheckFailure
+import io.legado.app.domain.gateway.BookSourceCheckResult
 import io.legado.app.domain.gateway.BookSourceCheckState
 import io.legado.app.domain.gateway.BookSourceCheckState
+import io.legado.app.domain.gateway.BookSourceCheckStatus
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.exception.ContentEmptyException
 import io.legado.app.exception.ContentEmptyException
@@ -36,26 +39,79 @@ class BookSourceCheckRepository(
     override val state = _state.asStateFlow()
     override val state = _state.asStateFlow()
     override suspend fun check(sourceIds: Set<String>, keyword: String) {
     override suspend fun check(sourceIds: Set<String>, keyword: String) {
         if (sourceIds.isEmpty() || _state.value.isRunning) return
         if (sourceIds.isEmpty() || _state.value.isRunning) return
-        _state.value = BookSourceCheckState(isRunning = true, total = sourceIds.size)
+        _state.value = BookSourceCheckState(
+            isRunning = true,
+            total = sourceIds.size,
+            results = sourceIds.associateWith {
+                BookSourceCheckResult(BookSourceCheckStatus.Pending)
+            },
+        )
         val dispatcher = Executors.newFixedThreadPool(
         val dispatcher = Executors.newFixedThreadPool(
             downloadCacheSettingsGateway.currentSettings.threadCount.coerceAtLeast(1)
             downloadCacheSettingsGateway.currentSettings.threadCount.coerceAtLeast(1)
         ).asCoroutineDispatcher()
         ).asCoroutineDispatcher()
+        var completedNormally = false
         try {
         try {
             coroutineScope {
             coroutineScope {
                 sourceIds.map { id ->
                 sourceIds.map { id ->
-                    async(dispatcher) { checkOne(id, keyword.ifBlank { "我的" }) }
+                    async(dispatcher) {
+                        runCatching { checkOne(id, keyword.ifBlank { "我的" }) }
+                            .onFailure { error ->
+                                coroutineContext.ensureActive()
+                                updateResult(
+                                    id = id,
+                                    name = "",
+                                    status = BookSourceCheckStatus.Failed,
+                                    failure = BookSourceCheckFailure.CheckFailed,
+                                    detail = error.displayMessage(),
+                                )
+                            }
+                    }
                 }.awaitAll()
                 }.awaitAll()
             }
             }
+            completedNormally = true
         } finally {
         } finally {
             dispatcher.close()
             dispatcher.close()
-            _state.update { it.copy(isRunning = false, currentSourceName = "") }
+            val unfinishedStatus = if (completedNormally) {
+                BookSourceCheckStatus.Failed
+            } else {
+                BookSourceCheckStatus.Cancelled
+            }
+            _state.update { state ->
+                val results = state.results.mapValues { (_, result) ->
+                    if (result.status.isTerminal) result
+                    else BookSourceCheckResult(
+                        status = unfinishedStatus,
+                        failure = BookSourceCheckFailure.Incomplete
+                            .takeIf { completedNormally },
+                    )
+                }
+                state.copy(
+                    isRunning = false,
+                    completed = results.values.count { it.status.isTerminal },
+                    currentSourceName = "",
+                    results = results,
+                )
+            }
         }
         }
     }
     }
 
 
     private suspend fun checkOne(sourceId: String, keyword: String) {
     private suspend fun checkOne(sourceId: String, keyword: String) {
-        val source = bookSourceRepository.getBookSource(sourceId) ?: return
+        val source = bookSourceRepository.getBookSource(sourceId)
+        if (source == null) {
+            updateResult(
+                id = sourceId,
+                name = "",
+                status = BookSourceCheckStatus.Failed,
+                failure = BookSourceCheckFailure.SourceMissing,
+            )
+            return
+        }
         val startedAt = System.currentTimeMillis()
         val startedAt = System.currentTimeMillis()
-        updateResult(sourceId, source.bookSourceName, "开始校验")
+        updateResult(
+            sourceId,
+            source.bookSourceName,
+            BookSourceCheckStatus.Running,
+        )
         val result = runCatching {
         val result = runCatching {
             withTimeout(settingsGateway.currentSettings.timeoutMillis) {
             withTimeout(settingsGateway.currentSettings.timeoutMillis) {
                 checkSource(source, keyword)
                 checkSource(source, keyword)
@@ -63,7 +119,6 @@ class BookSourceCheckRepository(
         }
         }
         result.onSuccess {
         result.onSuccess {
             source.respondTime = System.currentTimeMillis() - startedAt
             source.respondTime = System.currentTimeMillis() - startedAt
-            updateResult(sourceId, source.bookSourceName, "校验成功", completed = true)
         }.onFailure { error ->
         }.onFailure { error ->
             coroutineContext.ensureActive()
             coroutineContext.ensureActive()
             when (error) {
             when (error) {
@@ -74,27 +129,54 @@ class BookSourceCheckRepository(
             source.addErrorComment(error)
             source.addErrorComment(error)
             source.respondTime =
             source.respondTime =
                 settingsGateway.currentSettings.timeoutMillis + System.currentTimeMillis() - startedAt
                 settingsGateway.currentSettings.timeoutMillis + System.currentTimeMillis() - startedAt
-            updateResult(
+        }
+        val saveResult = runCatching { bookSourceRepository.updateSources(source) }
+        coroutineContext.ensureActive()
+        when {
+            result.isFailure -> updateResult(
+                sourceId,
+                source.bookSourceName,
+                BookSourceCheckStatus.Failed,
+                BookSourceCheckFailure.CheckFailed,
+                result.exceptionOrNull().displayMessage(),
+            )
+
+            saveResult.isFailure -> updateResult(
                 sourceId,
                 sourceId,
                 source.bookSourceName,
                 source.bookSourceName,
-                "校验失败:${error.localizedMessage}",
-                completed = true
+                BookSourceCheckStatus.Failed,
+                BookSourceCheckFailure.SaveFailed,
+                saveResult.exceptionOrNull().displayMessage(),
+            )
+
+            else -> updateResult(
+                sourceId,
+                source.bookSourceName,
+                BookSourceCheckStatus.Succeeded,
             )
             )
         }
         }
-        bookSourceRepository.updateSources(source)
     }
     }
 
 
     private fun updateResult(
     private fun updateResult(
         id: String,
         id: String,
         name: String,
         name: String,
-        message: String,
-        completed: Boolean = false
+        status: BookSourceCheckStatus,
+        failure: BookSourceCheckFailure? = null,
+        detail: String? = null,
     ) {
     ) {
         _state.update { state ->
         _state.update { state ->
+            val previous = state.results[id]
+            val completed = when {
+                previous?.status?.isTerminal == true -> state.completed
+                status.isTerminal -> state.completed + 1
+                else -> state.completed
+            }
             state.copy(
             state.copy(
-                completed = state.completed + if (completed) 1 else 0,
-                currentSourceName = name,
-                results = state.results + (id to message),
+                completed = completed,
+                currentSourceName = name.ifBlank { state.currentSourceName },
+                results = state.results + (
+                    id to BookSourceCheckResult(status, failure, detail)
+                ),
             )
             )
         }
         }
     }
     }
@@ -161,3 +243,7 @@ class BookSourceCheckRepository(
         }
         }
     }
     }
 }
 }
+
+private fun Throwable?.displayMessage(): String? =
+    this?.localizedMessage?.takeIf { it.isNotBlank() }
+        ?: this?.javaClass?.simpleName?.takeIf { it.isNotBlank() }

+ 2 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfSettingsRepository.kt

@@ -63,6 +63,7 @@ internal fun Preferences.toBookshelfSettings() = BookshelfSettings(
     bookshelfFolderLayoutListLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutListLandscape) ?: 1,
     bookshelfFolderLayoutListLandscape = compatDsInt(PreferKey.bookshelfFolderLayoutListLandscape) ?: 1,
     bookshelfGridLayout = compatDsInt(PreferKey.bookshelfGridLayout) ?: 0,
     bookshelfGridLayout = compatDsInt(PreferKey.bookshelfGridLayout) ?: 0,
     bookshelfLayoutCompact = compatDsBoolean(PreferKey.bookshelfLayoutCompact) ?: false,
     bookshelfLayoutCompact = compatDsBoolean(PreferKey.bookshelfLayoutCompact) ?: false,
+    bookshelfListCoverCenter = compatDsBoolean(PreferKey.bookshelfListCoverCenter) ?: true,
     bookshelfListIntroBelowContent = compatDsBoolean(PreferKey.bookshelfListIntroBelowContent)
     bookshelfListIntroBelowContent = compatDsBoolean(PreferKey.bookshelfListIntroBelowContent)
         ?: false,
         ?: false,
     bookshelfShowDivider = compatDsBoolean(PreferKey.bookshelfShowDivider) ?: true,
     bookshelfShowDivider = compatDsBoolean(PreferKey.bookshelfShowDivider) ?: true,
@@ -114,6 +115,7 @@ internal fun BookshelfSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfListCoverCenter to bookshelfListCoverCenter,
     PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,

+ 6 - 5
app/src/main/java/io/legado/app/di/appModule.kt

@@ -1,10 +1,11 @@
 package io.legado.app.di
 package io.legado.app.di
 
 
 import android.os.Build
 import android.os.Build
-import coil.ImageLoader
-import coil.decode.GifDecoder
-import coil.decode.ImageDecoderDecoder
-import coil.decode.SvgDecoder
+import coil3.ImageLoader
+import coil3.gif.AnimatedImageDecoder
+import coil3.gif.GifDecoder
+import coil3.request.crossfade
+import coil3.svg.SvgDecoder
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiArtifactRepository
 import io.legado.app.data.repository.AiChatRepository
 import io.legado.app.data.repository.AiChatRepository
@@ -486,7 +487,7 @@ val appModule = module {
         ImageLoader.Builder(get())
         ImageLoader.Builder(get())
             .components {
             .components {
                 if (Build.VERSION.SDK_INT >= 28) {
                 if (Build.VERSION.SDK_INT >= 28) {
-                    add(ImageDecoderDecoder.Factory())
+                    add(AnimatedImageDecoder.Factory())
                 } else {
                 } else {
                     add(GifDecoder.Factory())
                     add(GifDecoder.Factory())
                 }
                 }

+ 32 - 2
app/src/main/java/io/legado/app/domain/gateway/BookSourceCheckGateway.kt

@@ -2,13 +2,43 @@ package io.legado.app.domain.gateway
 
 
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
 
 
+enum class BookSourceCheckStatus(val isTerminal: Boolean) {
+    Pending(false),
+    Running(false),
+    Succeeded(true),
+    Failed(true),
+    Cancelled(true),
+}
+
+enum class BookSourceCheckFailure {
+    CheckFailed,
+    SourceMissing,
+    SaveFailed,
+    Incomplete,
+}
+
+data class BookSourceCheckResult(
+    val status: BookSourceCheckStatus,
+    val failure: BookSourceCheckFailure? = null,
+    val detail: String? = null,
+)
+
 data class BookSourceCheckState(
 data class BookSourceCheckState(
     val isRunning: Boolean = false,
     val isRunning: Boolean = false,
     val total: Int = 0,
     val total: Int = 0,
     val completed: Int = 0,
     val completed: Int = 0,
     val currentSourceName: String = "",
     val currentSourceName: String = "",
-    val results: Map<String, String> = emptyMap(),
-)
+    val results: Map<String, BookSourceCheckResult> = emptyMap(),
+) {
+    val succeededCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Succeeded }
+
+    val failedCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Failed }
+
+    val cancelledCount: Int
+        get() = results.values.count { it.status == BookSourceCheckStatus.Cancelled }
+}
 
 
 interface BookSourceCheckGateway {
 interface BookSourceCheckGateway {
     val state: StateFlow<BookSourceCheckState>
     val state: StateFlow<BookSourceCheckState>

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/BookshelfSettings.kt

@@ -35,6 +35,7 @@ data class BookshelfSettings(
     val bookshelfFolderLayoutListLandscape: Int = 1,
     val bookshelfFolderLayoutListLandscape: Int = 1,
     val bookshelfGridLayout: Int = 0,
     val bookshelfGridLayout: Int = 0,
     val bookshelfLayoutCompact: Boolean = false,
     val bookshelfLayoutCompact: Boolean = false,
+    val bookshelfListCoverCenter: Boolean = true,
     val bookshelfListIntroBelowContent: Boolean = false,
     val bookshelfListIntroBelowContent: Boolean = false,
     val bookshelfShowDivider: Boolean = true,
     val bookshelfShowDivider: Boolean = true,
     val bookshelfTitleSmallFont: Boolean = false,
     val bookshelfTitleSmallFont: Boolean = false,

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

@@ -0,0 +1,26 @@
+package io.legado.app.help.coil
+
+import coil3.Extras
+import io.legado.app.data.entities.BaseSource
+
+/**
+ * 封面请求在 Coil 3 中通过 [Extras] 传参。Extras.Key 只按实例相等比较,
+ * 因此写入方(封面请求构造)与读取方(CoverInterceptor / CoverFetcher)
+ * 必须共享这组单例 key。
+ */
+object CoverExtras {
+    /** 书源标识,CoverInterceptor 用它解析最终 URL 和请求头。 */
+    val SourceOrigin = Extras.Key<String?>(null)
+
+    /** CoverInterceptor 解析得到的书源,CoverFetcher 用它解密图片。 */
+    val Source = Extras.Key<BaseSource?>(null)
+
+    /** CoverInterceptor 解析得到的请求头,CoverFetcher 构造 OkHttp 请求用。 */
+    val Headers = Extras.Key<Map<String, String>?>(null)
+
+    /** 仅 WiFi 时加载。 */
+    val LoadOnlyWifi = Extras.Key<Boolean?>(null)
+
+    /** 漫画模式。 */
+    val Manga = Extras.Key<Boolean?>(null)
+}

+ 27 - 23
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -1,16 +1,13 @@
 package io.legado.app.help.coil
 package io.legado.app.help.coil
 
 
-import android.net.Uri
 import android.util.Base64
 import android.util.Base64
-import coil.ImageLoader
-import coil.decode.DataSource
-import coil.decode.ImageSource
-import coil.fetch.FetchResult
-import coil.fetch.Fetcher
-import coil.fetch.SourceResult
-import coil.network.HttpException
-import coil.request.Options
-import io.legado.app.data.entities.BaseSource
+import coil3.ImageLoader
+import coil3.decode.DataSource
+import coil3.decode.ImageSource
+import coil3.fetch.FetchResult
+import coil3.fetch.Fetcher
+import coil3.fetch.SourceFetchResult
+import coil3.request.Options
 import io.legado.app.model.ReadManga
 import io.legado.app.model.ReadManga
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.ImageUtils
 import io.legado.app.utils.isWifiConnect
 import io.legado.app.utils.isWifiConnect
@@ -61,8 +58,8 @@ class CoverFetcher(
     }
     }
 
 
     override suspend fun fetch(): FetchResult {
     override suspend fun fetch(): FetchResult {
-        val source = options.tags.tag<BaseSource>()
-        val isManga = options.parameters.value("manga") as? Boolean == true
+        val source = options.extras[CoverExtras.Source]
+        val isManga = options.extras[CoverExtras.Manga] == true
 
 
         if (url.startsWith("data:", true)) {
         if (url.startsWith("data:", true)) {
             val base64Data = url.substringAfter("base64,", "")
             val base64Data = url.substringAfter("base64,", "")
@@ -70,8 +67,11 @@ class CoverFetcher(
                 throw IOException("Invalid data URI")
                 throw IOException("Invalid data URI")
             }
             }
             val bytes = Base64.decode(base64Data, Base64.DEFAULT)
             val bytes = Base64.decode(base64Data, Base64.DEFAULT)
-            return SourceResult(
-                source = ImageSource(source = Buffer().write(bytes), context = options.context),
+            return SourceFetchResult(
+                source = ImageSource(
+                    source = Buffer().write(bytes),
+                    fileSystem = options.fileSystem
+                ),
                 mimeType = null,
                 mimeType = null,
                 dataSource = DataSource.MEMORY
                 dataSource = DataSource.MEMORY
             )
             )
@@ -86,9 +86,10 @@ class CoverFetcher(
         }
         }
 
 
         // Try OkHttp HTTP cache: FORCE_CACHE serves from cache or returns 504 on miss
         // Try OkHttp HTTP cache: FORCE_CACHE serves from cache or returns 504 on miss
+        val requestHeaders = options.extras[CoverExtras.Headers]
         val cacheRequest = Request.Builder()
         val cacheRequest = Request.Builder()
             .url(url)
             .url(url)
-            .headers(options.headers)
+            .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
             .cacheControl(CacheControl.FORCE_CACHE)
             .cacheControl(CacheControl.FORCE_CACHE)
             .build()
             .build()
 
 
@@ -104,7 +105,7 @@ class CoverFetcher(
                     // Cache miss, fetch from network
                     // Cache miss, fetch from network
                     val networkRequest = Request.Builder()
                     val networkRequest = Request.Builder()
                         .url(url)
                         .url(url)
-                        .headers(options.headers)
+                        .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
                         .tag(COVER_REQUEST_TAG)
                         .tag(COVER_REQUEST_TAG)
                         .cacheControl(
                         .cacheControl(
                             CacheControl.Builder()
                             CacheControl.Builder()
@@ -116,7 +117,7 @@ class CoverFetcher(
                     val body = networkResponse.body
                     val body = networkResponse.body
                     if (!networkResponse.isSuccessful) {
                     if (!networkResponse.isSuccessful) {
                         body.close()
                         body.close()
-                        throw HttpException(networkResponse)
+                        throw IOException("HTTP ${networkResponse.code}")
                     }
                     }
                     body.use { it.bytes() }
                     body.use { it.bytes() }
                 }
                 }
@@ -139,8 +140,11 @@ class CoverFetcher(
             } ?: throw IOException("图片解密失败")
             } ?: throw IOException("图片解密失败")
         }
         }
 
 
-        return SourceResult(
-            source = ImageSource(source = Buffer().write(decodedBytes), context = options.context),
+        return SourceFetchResult(
+            source = ImageSource(
+                source = Buffer().write(decodedBytes),
+                fileSystem = options.fileSystem
+            ),
             mimeType = null,
             mimeType = null,
             dataSource = if (fromCache) DataSource.DISK else DataSource.NETWORK
             dataSource = if (fromCache) DataSource.DISK else DataSource.NETWORK
         )
         )
@@ -149,13 +153,13 @@ class CoverFetcher(
     class Factory(
     class Factory(
         private val okHttpClient: OkHttpClient,
         private val okHttpClient: OkHttpClient,
         private val okHttpClientManga: OkHttpClient,
         private val okHttpClientManga: OkHttpClient,
-    ) : Fetcher.Factory<Uri> {
-        override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
+    ) : Fetcher.Factory<coil3.Uri> {
+        override fun create(data: coil3.Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
             val scheme = data.scheme
             val scheme = data.scheme
             if (scheme != "http" && scheme != "https" && scheme != "data") return null
             if (scheme != "http" && scheme != "https" && scheme != "data") return null
 
 
-            val isManga = options.parameters.value("manga") as? Boolean == true
-            val loadOnlyWifi = options.parameters.value("loadOnlyWifi") as? Boolean == true
+            val isManga = options.extras[CoverExtras.Manga] == true
+            val loadOnlyWifi = options.extras[CoverExtras.LoadOnlyWifi] == true
             val client = if (isManga) okHttpClientManga else okHttpClient
             val client = if (isManga) okHttpClientManga else okHttpClient
 
 
             return CoverFetcher(data.toString(), options, client, loadOnlyWifi)
             return CoverFetcher(data.toString(), options, client, loadOnlyWifi)

+ 7 - 10
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -1,8 +1,7 @@
 package io.legado.app.help.coil
 package io.legado.app.help.coil
 
 
-import coil.intercept.Interceptor
-import coil.request.ImageResult
-import io.legado.app.data.entities.BaseSource
+import coil3.intercept.Interceptor
+import coil3.request.ImageResult
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
@@ -36,7 +35,7 @@ class CoverInterceptor : Interceptor {
         val data = request.data
         val data = request.data
 
 
         if (data is String && data.isNotBlank()) {
         if (data is String && data.isNotBlank()) {
-            val sourceOrigin = request.parameters.value("sourceOrigin") as? String
+            val sourceOrigin = request.extras[CoverExtras.SourceOrigin]
             val source = sourceOrigin?.let { origin ->
             val source = sourceOrigin?.let { origin ->
                 withContext(Dispatchers.IO) {
                 withContext(Dispatchers.IO) {
                     SourceHelp.getSource(origin)
                     SourceHelp.getSource(origin)
@@ -59,15 +58,13 @@ class CoverInterceptor : Interceptor {
             val newRequest = request.newBuilder()
             val newRequest = request.newBuilder()
                 .data(finalUrl)
                 .data(finalUrl)
                 .apply {
                 .apply {
-                    headers.forEach { (key, value) ->
-                        addHeader(key, value)
-                    }
-                    tag(BaseSource::class.java, source)
+                    extras[CoverExtras.Source] = source
+                    extras[CoverExtras.Headers] = headers
                 }
                 }
                 .build()
                 .build()
 
 
-            return chain.proceed(newRequest)
+            return chain.withRequest(newRequest).proceed()
         }
         }
-        return chain.proceed(request)
+        return chain.proceed()
     }
     }
 }
 }

+ 1 - 1
app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt

@@ -11,7 +11,7 @@ fun TextChapter.toCanonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> {
         .mapIndexed { fallbackIndex, paragraph ->
         .mapIndexed { fallbackIndex, paragraph ->
             CanonicalSpeechParagraph(
             CanonicalSpeechParagraph(
                 index = (paragraph.realNum - 1).takeIf { it >= 0 } ?: fallbackIndex,
                 index = (paragraph.realNum - 1).takeIf { it >= 0 } ?: fallbackIndex,
-                text = paragraph.text,
+                text = paragraph.text.replace(Regex("[袮祢꧁]"), " "),
                 chapterPosition = paragraph.chapterPosition,
                 chapterPosition = paragraph.chapterPosition,
             )
             )
         }
         }

+ 8 - 12
app/src/main/java/io/legado/app/help/source/SourceVerificationHelp.kt

@@ -6,7 +6,7 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
 import io.legado.app.help.IntentData
 import io.legado.app.help.IntentData
 import io.legado.app.ui.association.VerificationCodeActivity
 import io.legado.app.ui.association.VerificationCodeActivity
-import io.legado.app.ui.browser.WebViewActivity
+import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.isMainThread
 import io.legado.app.utils.isMainThread
 import io.legado.app.utils.startActivity
 import io.legado.app.utils.startActivity
 import splitties.init.appCtx
 import splitties.init.appCtx
@@ -85,17 +85,13 @@ object SourceVerificationHelp {
     ) {
     ) {
         source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
         source ?: throw NoStackTraceException("startBrowser parameter source cannot be null")
         require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
         require(url.length < 64 * 1024) { "startBrowser parameter url too long" }
-        appCtx.startActivity<WebViewActivity> {
-            putExtra("title", title)
-            putExtra("url", url)
-            putExtra("sourceOrigin", source.getKey())
-            putExtra("sourceName", source.getTag())
-            putExtra("sourceType", source.getSourceType())
-            putExtra("sourceVerificationEnable", saveResult)
-            putExtra("refetchAfterSuccess", refetchAfterSuccess)
-            putExtra("html", html)
-            IntentData.put(getVerificationResultKey(source), Thread.currentThread())
-        }
+        IntentData.put(getVerificationResultKey(source), Thread.currentThread())
+        appCtx.startActivity(
+            MainActivity.createWebViewIntent(
+                appCtx, title, url, source.getKey(), source.getTag(), source.getSourceType(),
+                saveResult == true, refetchAfterSuccess != false, html,
+            )
+        )
     }
     }
 
 
 
 

+ 1 - 0
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -306,6 +306,7 @@ object BackupConfig {
         PreferKey.bookshelfLayoutModePortrait,
         PreferKey.bookshelfLayoutModePortrait,
         PreferKey.bookshelfLayoutModeLandscape,
         PreferKey.bookshelfLayoutModeLandscape,
         PreferKey.bookshelfLayoutCompact,
         PreferKey.bookshelfLayoutCompact,
+        PreferKey.bookshelfListCoverCenter,
         PreferKey.bookshelfListIntroBelowContent,
         PreferKey.bookshelfListIntroBelowContent,
         PreferKey.bookshelfShowDivider,
         PreferKey.bookshelfShowDivider,
         PreferKey.bookshelfGridLayout,
         PreferKey.bookshelfGridLayout,

+ 205 - 0
app/src/main/java/io/legado/app/service/BookSourceCheckService.kt

@@ -0,0 +1,205 @@
+package io.legado.app.service
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.os.PowerManager
+import androidx.core.app.NotificationCompat
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.R
+import io.legado.app.base.BaseService
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.IntentAction
+import io.legado.app.constant.NotificationId
+import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.usecase.StartBookSourceCheckUseCase
+import io.legado.app.help.IntentData
+import io.legado.app.ui.main.MainActivity
+import io.legado.app.utils.activityPendingIntent
+import io.legado.app.utils.servicePendingIntent
+import io.legado.app.utils.startForegroundServiceCompat
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.koin.android.ext.android.inject
+import splitties.systemservices.notificationManager
+import splitties.systemservices.powerManager
+
+class BookSourceCheckService : BaseService() {
+
+    companion object {
+        private const val EXTRA_REQUEST_KEY = "bookSourceCheckRequestKey"
+
+        fun start(context: Context, ids: Set<String>, keyword: String) {
+            val requestKey = IntentData.put(CheckRequest(ids, keyword))
+            val intent = Intent(context, BookSourceCheckService::class.java).apply {
+                action = IntentAction.start
+                putExtra(EXTRA_REQUEST_KEY, requestKey)
+            }
+            context.startForegroundServiceCompat(intent)
+        }
+
+        fun stop(context: Context) {
+            context.startService(
+                Intent(context, BookSourceCheckService::class.java).apply {
+                    action = IntentAction.stop
+                }
+            )
+        }
+    }
+
+    private data class CheckRequest(
+        val ids: Set<String>,
+        val keyword: String,
+    )
+
+    private val checkGateway by inject<BookSourceCheckGateway>()
+    private val startBookSourceCheck by inject<StartBookSourceCheckUseCase>()
+    private var checkJob: Job? = null
+    private var finishedNotificationShown = false
+    private var wakeLockAcquired = false
+
+    private val wakeLock: PowerManager.WakeLock by lazy {
+        powerManager.newWakeLock(
+            PowerManager.PARTIAL_WAKE_LOCK,
+            "legado:BookSourceCheckService",
+        ).apply { setReferenceCounted(false) }
+    }
+
+    private val notificationBuilder by lazy {
+        NotificationCompat.Builder(this, AppConst.channelIdBookSourceCheck)
+            .setSmallIcon(R.drawable.ic_network_check)
+            .setContentTitle(getString(R.string.check_book_source))
+            .setContentIntent(
+                activityPendingIntent(
+                    MainActivity.createBookSourceManageIntent(this),
+                    "bookSourceCheck",
+                )
+            )
+            .setOnlyAlertOnce(true)
+            .setOngoing(true)
+            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .addAction(
+                R.drawable.ic_stop_black_24dp,
+                getString(R.string.cancel),
+                servicePendingIntent<BookSourceCheckService>(IntentAction.stop),
+            )
+    }
+
+    override fun onCreate() {
+        super.onCreate()
+        lifecycleScope.launch {
+            checkGateway.state.collectLatest { state ->
+                if (state.isRunning) {
+                    notificationBuilder
+                        .setContentText(
+                            getString(
+                                R.string.progress_show,
+                                state.currentSourceName,
+                                state.completed,
+                                state.total,
+                            )
+                        )
+                        .setProgress(state.total, state.completed, false)
+                    notificationManager.notify(
+                        NotificationId.BookSourceCheckService,
+                        notificationBuilder.build(),
+                    )
+                }
+            }
+        }
+    }
+
+    @SuppressLint("WakelockTimeout")
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        super.onStartCommand(intent, flags, startId)
+        when (intent?.action) {
+            IntentAction.start -> {
+                val request = IntentData.get<CheckRequest>(
+                    intent.getStringExtra(EXTRA_REQUEST_KEY)
+                )
+                if (request == null) {
+                    stopWithoutResultNotification()
+                } else if (checkJob?.isActive != true && !checkGateway.state.value.isRunning) {
+                    finishedNotificationShown = false
+                    wakeLock.acquire()
+                    wakeLockAcquired = true
+                    checkJob = lifecycleScope.launch {
+                        try {
+                            startBookSourceCheck(request.ids, request.keyword)
+                        } finally {
+                            showFinishedNotification()
+                            releaseWakeLock()
+                            stopForeground(STOP_FOREGROUND_DETACH)
+                            stopSelf()
+                        }
+                    }
+                }
+            }
+
+            IntentAction.stop -> {
+                if (checkJob?.isActive == true) checkJob?.cancel()
+                else stopWithoutResultNotification()
+            }
+        }
+        return START_NOT_STICKY
+    }
+
+    override fun onDestroy() {
+        releaseWakeLock()
+        if (!finishedNotificationShown) {
+            notificationManager.cancel(NotificationId.BookSourceCheckService)
+        }
+        super.onDestroy()
+    }
+
+    override fun startForegroundNotification() {
+        notificationBuilder
+            .setContentText(getString(R.string.service_starting))
+            .setProgress(0, 0, true)
+        startForeground(
+            NotificationId.BookSourceCheckService,
+            notificationBuilder.build(),
+        )
+    }
+
+    private fun showFinishedNotification() {
+        val state = checkGateway.state.value
+        val content = if (state.cancelledCount > 0) {
+            getString(
+                R.string.book_source_check_cancelled,
+                state.succeededCount,
+                state.failedCount,
+                state.cancelledCount,
+            )
+        } else {
+            getString(
+                R.string.book_source_check_completed,
+                state.succeededCount,
+                state.failedCount,
+            )
+        }
+        notificationBuilder
+            .clearActions()
+            .setContentText(content)
+            .setProgress(0, 0, false)
+            .setOngoing(false)
+            .setAutoCancel(true)
+        notificationManager.notify(
+            NotificationId.BookSourceCheckService,
+            notificationBuilder.build(),
+        )
+        finishedNotificationShown = true
+    }
+
+    private fun stopWithoutResultNotification() {
+        stopForeground(STOP_FOREGROUND_REMOVE)
+        notificationManager.cancel(NotificationId.BookSourceCheckService)
+        stopSelf()
+    }
+
+    private fun releaseWakeLock() {
+        if (wakeLockAcquired && wakeLock.isHeld) wakeLock.release()
+        wakeLockAcquired = false
+    }
+}

+ 6 - 5
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -75,13 +75,14 @@ import androidx.compose.ui.semantics.role
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.ImageLoader
-import coil.compose.AsyncImage
-import coil.size.Size
+import coil3.ImageLoader
+import coil3.compose.AsyncImage
+import coil3.size.Size
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.BookType
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.help.coil.CoverExtras
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.LocalHazeState
@@ -603,8 +604,8 @@ private fun rememberBookInfoColorTheme(
         data = coverPath,
         data = coverPath,
         requestKey = requestKey,
         requestKey = requestKey,
     ) {
     ) {
-        setParameter("sourceOrigin", sourceOrigin)
-        setParameter("loadOnlyWifi", loadOnlyWifi)
+        extras[CoverExtras.SourceOrigin] = sourceOrigin
+        extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
     }
     }
 
 
     return rememberThemeOverride(seedColor)
     return rememberThemeOverride(seedColor)

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

@@ -5,10 +5,10 @@ import android.app.Application
 import android.content.Intent
 import android.content.Intent
 import android.graphics.Bitmap
 import android.graphics.Bitmap
 import android.net.Uri
 import android.net.Uri
-import androidx.core.graphics.drawable.toBitmap
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
-import coil.ImageLoader
-import coil.request.SuccessResult
+import coil3.ImageLoader
+import coil3.request.SuccessResult
+import coil3.toBitmap
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
@@ -647,7 +647,7 @@ class BookInfoViewModel(
             )
             )
             val result = imageLoader.execute(request)
             val result = imageLoader.execute(request)
             if (result is SuccessResult) {
             if (result is SuccessResult) {
-                val bitmap = result.drawable.toBitmap()
+                val bitmap = result.image.toBitmap()
                 val outputStream = ByteArrayOutputStream()
                 val outputStream = ByteArrayOutputStream()
                 bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                 bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                 val byteArray = outputStream.toByteArray()
                 val byteArray = outputStream.toByteArray()

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt

@@ -36,7 +36,7 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt

@@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.ui.ai.AiTaskResultSheet
 import io.legado.app.ui.ai.AiTaskResultSheet

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkScreen.kt

@@ -37,7 +37,7 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarCropDialog.kt

@@ -19,17 +19,17 @@ import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.layout.ScaleFactor
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Size
 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.ScaleFactor
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog

+ 11 - 10
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -72,7 +72,6 @@ import io.legado.app.ui.book.manga.recyclerview.ScrollTimer
 import io.legado.app.ui.book.manga.recyclerview.WebtoonFrame
 import io.legado.app.ui.book.manga.recyclerview.WebtoonFrame
 import io.legado.app.ui.book.read.MangaMenu
 import io.legado.app.ui.book.read.MangaMenu
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
-import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.login.SourceLoginType
@@ -92,7 +91,6 @@ import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.isTrue
 import io.legado.app.utils.isTrue
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.showDialogFragment
 import io.legado.app.utils.showDialogFragment
-import io.legado.app.utils.startActivity
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -699,14 +697,17 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
                     analyzeRule.evalJS(payAction).toString()
                     analyzeRule.evalJS(payAction).toString()
                 }.onSuccess(IO) {
                 }.onSuccess(IO) {
                     if (it.isAbsUrl()) {
                     if (it.isAbsUrl()) {
-                        startActivity<WebViewActivity> {
-                            val bookSource = ReadManga.bookSource
-                            putExtra("title", getString(R.string.chapter_pay))
-                            putExtra("url", it)
-                            putExtra("sourceOrigin", bookSource?.bookSourceUrl)
-                            putExtra("sourceName", bookSource?.bookSourceName)
-                            putExtra("sourceType", bookSource?.getSourceType())
-                        }
+                        val bookSource = ReadManga.bookSource
+                        startActivity(
+                            MainActivity.createWebViewIntent(
+                                this@ReadMangaActivity,
+                                getString(R.string.chapter_pay),
+                                it,
+                                bookSource?.bookSourceUrl,
+                                bookSource?.bookSourceName,
+                                bookSource?.getSourceType(),
+                            )
+                        )
                     } else if (it.isTrue()) {
                     } else if (it.isTrue()) {
                         //购买成功后刷新目录
                         //购买成功后刷新目录
                         ReadManga.book?.let {
                         ReadManga.book?.let {

+ 17 - 16
app/src/main/java/io/legado/app/ui/book/read/MangaMenu.kt

@@ -8,31 +8,30 @@ import android.view.LayoutInflater
 import android.view.animation.Animation
 import android.view.animation.Animation
 import android.widget.FrameLayout
 import android.widget.FrameLayout
 import androidx.appcompat.widget.PopupMenu
 import androidx.appcompat.widget.PopupMenu
-import androidx.core.view.ViewCompat
 import androidx.core.view.HapticFeedbackConstantsCompat
 import androidx.core.view.HapticFeedbackConstantsCompat
+import androidx.core.view.ViewCompat
 import androidx.core.view.isGone
 import androidx.core.view.isGone
 import androidx.core.view.isVisible
 import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
 import com.google.android.material.slider.Slider
 import com.google.android.material.slider.Slider
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.databinding.ViewMangaMenuBinding
 import io.legado.app.databinding.ViewMangaMenuBinding
+import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.help.source.getSourceType
 import io.legado.app.help.source.getSourceType
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadManga
 import io.legado.app.model.ReadManga
-import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.domain.gateway.ReadSettingsGateway
-import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.launch
-import org.koin.core.context.GlobalContext
+import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.activity
 import io.legado.app.utils.activity
 import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.gone
 import io.legado.app.utils.gone
 import io.legado.app.utils.invisible
 import io.legado.app.utils.invisible
 import io.legado.app.utils.loadAnimation
 import io.legado.app.utils.loadAnimation
 import io.legado.app.utils.openUrl
 import io.legado.app.utils.openUrl
-import io.legado.app.utils.startActivity
 import io.legado.app.utils.visible
 import io.legado.app.utils.visible
+import kotlinx.coroutines.launch
+import org.koin.core.context.GlobalContext
 
 
 class MangaMenu @JvmOverloads constructor(
 class MangaMenu @JvmOverloads constructor(
     context: Context,
     context: Context,
@@ -194,15 +193,17 @@ class MangaMenu @JvmOverloads constructor(
             if (ReadConfig.readUrlInBrowser) {
             if (ReadConfig.readUrlInBrowser) {
                 context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
                 context.openUrl(tvChapterUrl.text.toString().substringBefore(",{"))
             } else {
             } else {
-                context.startActivity<WebViewActivity> {
-                    val url = tvChapterUrl.text.toString()
-                    val bookSource = ReadBook.bookSource
-                    putExtra("title", tvChapterName.text)
-                    putExtra("url", url)
-                    putExtra("sourceOrigin", bookSource?.bookSourceUrl)
-                    putExtra("sourceName", bookSource?.bookSourceName)
-                    putExtra("sourceType", bookSource?.getSourceType())
-                }
+                val bookSource = ReadBook.bookSource
+                context.startActivity(
+                    MainActivity.createWebViewIntent(
+                        context,
+                        tvChapterName.text.toString(),
+                        tvChapterUrl.text.toString(),
+                        bookSource?.bookSourceUrl,
+                        bookSource?.bookSourceName,
+                        bookSource?.getSourceType(),
+                    )
+                )
             }
             }
         }
         }
         val chapterViewLongClickListener = OnLongClickListener {
         val chapterViewLongClickListener = OnLongClickListener {

+ 11 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -411,10 +411,19 @@ internal val MoreActionIds = listOf(
     "bottom_button_config", "log",
     "bottom_button_config", "log",
 )
 )
 
 
+@Immutable
+data class ReadBookInitRequest(
+    val bookUrl: String? = null,
+    val inBookshelf: Boolean = true,
+    val chapterChanged: Boolean = false,
+    val chapterIndex: Int = -1,
+    val chapterPos: Int = -1,
+)
+
 sealed interface ReadBookIntent {
 sealed interface ReadBookIntent {
     // Initialization
     // Initialization
-    data class InitData(val intent: android.content.Intent) : ReadBookIntent
-    data class InitReadBookConfig(val intent: android.content.Intent) : ReadBookIntent
+    data class InitData(val request: ReadBookInitRequest) : ReadBookIntent
+    data class InitReadBookConfig(val request: ReadBookInitRequest) : ReadBookIntent
     data class CheckSwitchDayNight(val lux: Float) : ReadBookIntent
     data class CheckSwitchDayNight(val lux: Float) : ReadBookIntent
     data object DismissReminder : ReadBookIntent
     data object DismissReminder : ReadBookIntent
 
 

+ 9 - 10
app/src/main/java/io/legado/app/ui/book/read/ReadBookLoadDelegate.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.book.read
 package io.legado.app.ui.book.read
 
 
 import android.content.Context
 import android.content.Context
-import android.content.Intent
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
@@ -48,7 +47,7 @@ import kotlin.coroutines.coroutineContext
 /**
 /**
  * 开书 / 目录加载 / 换源 / 进度同步域(R2.2 续批)。
  * 开书 / 目录加载 / 换源 / 进度同步域(R2.2 续批)。
  *
  *
- * 从 intent 解析出书,装载目录与正文,处理本地文件缺失、换源(手动与自动)、
+ * 从导航请求解析出书,装载目录与正文,处理本地文件缺失、换源(手动与自动)、
  * 以及与云端阅读进度的双向同步。
  * 以及与云端阅读进度的双向同步。
  *
  *
  * **无自持状态**:唯一的状态是 `isInitFinish`(ReadView 首帧靠它放行前后章排版,
  * **无自持状态**:唯一的状态是 `isInitFinish`(ReadView 首帧靠它放行前后章排版,
@@ -92,8 +91,8 @@ class ReadBookLoadDelegate(
 
 
     private var changeSourceCoroutine: Coroutine<*>? = null
     private var changeSourceCoroutine: Coroutine<*>? = null
 
 
-    suspend fun initReadBookConfig(intent: Intent) {
-        val bookUrl = intent.getStringExtra("bookUrl")
+    suspend fun initReadBookConfig(request: ReadBookInitRequest) {
+        val bookUrl = request.bookUrl
         val book = when {
         val book = when {
             bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
             bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
             else -> bookRepository.getBook(bookUrl)
             else -> bookRepository.getBook(bookUrl)
@@ -101,12 +100,12 @@ class ReadBookLoadDelegate(
         ReadBook.upReadBookConfig(book)
         ReadBook.upReadBookConfig(book)
     }
     }
 
 
-    fun initData(intent: Intent, success: (() -> Unit)? = null) {
+    fun initData(request: ReadBookInitRequest, success: (() -> Unit)? = null) {
         Coroutine.async(scope, Dispatchers.IO) {
         Coroutine.async(scope, Dispatchers.IO) {
             host.syncReadPreferencesSnapshot()
             host.syncReadPreferencesSnapshot()
-            ReadBook.inBookshelf = intent.getBooleanExtra("inBookshelf", true)
-            ReadBook.chapterChanged = intent.getBooleanExtra("chapterChanged", false)
-            val bookUrl = intent.getStringExtra("bookUrl")
+            ReadBook.inBookshelf = request.inBookshelf
+            ReadBook.chapterChanged = request.chapterChanged
+            val bookUrl = request.bookUrl
             val book = when {
             val book = when {
                 bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
                 bookUrl.isNullOrEmpty() -> bookRepository.getLastReadBook()
                 else -> bookRepository.getBook(bookUrl)
                 else -> bookRepository.getBook(bookUrl)
@@ -118,8 +117,8 @@ class ReadBookLoadDelegate(
                     AppLog.put("未找到书籍\nbookUrl:$bookUrl")
                     AppLog.put("未找到书籍\nbookUrl:$bookUrl")
                 }
                 }
             }
             }
-            val index = intent.getIntExtra("index", -1)
-            val chapterPos = intent.getIntExtra("chapterPos", -1)
+            val index = request.chapterIndex
+            val chapterPos = request.chapterPos
             if (index >= 0 && chapterPos >= 0) {
             if (index >= 0 && chapterPos >= 0) {
                 ReadBook.saveCurrentBookProgress()
                 ReadBook.saveCurrentBookProgress()
                 host.openChapter(index, chapterPos)
                 host.openChapter(index, chapterPos)

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarFloating.kt

@@ -46,7 +46,7 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 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 coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import com.kyant.backdrop.Backdrop
 import com.kyant.backdrop.Backdrop
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBarTool.kt

@@ -42,7 +42,7 @@ import androidx.compose.ui.semantics.setProgress
 import androidx.compose.ui.semantics.stateDescription
 import androidx.compose.ui.semantics.stateDescription
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.LayoutDirection
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import com.kyant.backdrop.Backdrop
 import com.kyant.backdrop.Backdrop
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberBackdrop
 import com.kyant.backdrop.backdrops.rememberBackdrop

+ 4 - 9
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -62,7 +62,6 @@ import io.legado.app.ui.book.read.sheet.ReaderBookSheetRoute
 import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
-import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.login.SourceLoginType
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceEditRoute
@@ -348,14 +347,10 @@ fun ReadBookRouteScreen(
                             }
                             }
                             is ReadBookEffect.OpenWebView -> {
                             is ReadBookEffect.OpenWebView -> {
                                 context.startActivity(
                                 context.startActivity(
-                                    Intent(context, WebViewActivity::class.java).apply {
-                                        putExtra("title", effect.title)
-                                        putExtra("url", effect.url)
-                                        putExtra("sourceOrigin", effect.sourceOrigin)
-                                        putExtra("sourceName", effect.sourceName)
-                                        effect.sourceType?.let { putExtra("sourceType", it) }
-                                        effect.html?.let { putExtra("html", it) }
-                                    }
+                                    MainActivity.createWebViewIntent(
+                                        context, effect.title, effect.url, effect.sourceOrigin,
+                                        effect.sourceName, effect.sourceType, html = effect.html,
+                                    )
                                 )
                                 )
                             }
                             }
                             is ReadBookEffect.RunSourceCustomButton -> {
                             is ReadBookEffect.RunSourceCustomButton -> {

+ 4 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -11,10 +11,11 @@ import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.core.net.toUri
 import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.ImageLoader
+import coil3.ImageLoader
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.help.coil.CoverExtras
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
 import io.legado.app.ui.book.read.sheet.AiTextRewriteSheet
 import io.legado.app.ui.book.read.sheet.AiTextRewriteSheet
@@ -414,8 +415,8 @@ fun ReadBookScreen(
             data = coverPath,
             data = coverPath,
             requestKey = requestKey,
             requestKey = requestKey,
         ) {
         ) {
-            setParameter("sourceOrigin", sourceOrigin)
-            setParameter("loadOnlyWifi", loadOnlyWifi)
+            extras[CoverExtras.SourceOrigin] = sourceOrigin
+            extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
         }
         }
         rememberThemeOverride(seedColor)
         rememberThemeOverride(seedColor)
     }
     }
@@ -440,7 +441,6 @@ fun ReadBookScreen(
             )
             )
             aloudPlayerViewModel.effects.collectLatest { effect ->
             aloudPlayerViewModel.effects.collectLatest { effect ->
                 when (effect) {
                 when (effect) {
-                    ReadAloudPlayerEffect.OpenToc -> onIntent(ReadBookIntent.OpenChapterList)
                     ReadAloudPlayerEffect.ReturnToReaderSettings ->
                     ReadAloudPlayerEffect.ReturnToReaderSettings ->
                         onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
                         onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
                     ReadAloudPlayerEffect.ReturnToClassic ->
                     ReadAloudPlayerEffect.ReturnToClassic ->

+ 6 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.book.read
 package io.legado.app.ui.book.read
 
 
 import android.app.Application
 import android.app.Application
-import android.content.Intent
 import android.net.Uri
 import android.net.Uri
 import android.speech.tts.TextToSpeech
 import android.speech.tts.TextToSpeech
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
@@ -641,11 +640,11 @@ class ReadBookViewModel(
     fun onIntent(intent: ReadBookIntent) {
     fun onIntent(intent: ReadBookIntent) {
         when (intent) {
         when (intent) {
             is ReadBookIntent.InitData -> {
             is ReadBookIntent.InitData -> {
-                initData(intent.intent)
+                initData(intent.request)
                 justInitData = true
                 justInitData = true
             }
             }
             is ReadBookIntent.InitReadBookConfig -> viewModelScope.launch {
             is ReadBookIntent.InitReadBookConfig -> viewModelScope.launch {
-                initReadBookConfig(intent.intent)
+                initReadBookConfig(intent.request)
             }
             }
             is ReadBookIntent.CheckSwitchDayNight -> styleDelegate.checkSwitchDayNight(intent.lux)
             is ReadBookIntent.CheckSwitchDayNight -> styleDelegate.checkSwitchDayNight(intent.lux)
             is ReadBookIntent.DismissReminder -> styleDelegate.dismissReminder()
             is ReadBookIntent.DismissReminder -> styleDelegate.dismissReminder()
@@ -1982,10 +1981,11 @@ class ReadBookViewModel(
 
 
     // 开书 / 目录 / 换源 / 进度同步已迁入 [ReadBookLoadDelegate],这里只留外部入口的转发。
     // 开书 / 目录 / 换源 / 进度同步已迁入 [ReadBookLoadDelegate],这里只留外部入口的转发。
 
 
-    suspend fun initReadBookConfig(intent: Intent) = loadDelegate.initReadBookConfig(intent)
+    suspend fun initReadBookConfig(request: ReadBookInitRequest) =
+        loadDelegate.initReadBookConfig(request)
 
 
-    fun initData(intent: Intent, success: (() -> Unit)? = null) =
-        loadDelegate.initData(intent, success)
+    fun initData(request: ReadBookInitRequest, success: (() -> Unit)? = null) =
+        loadDelegate.initData(request, success)
 
 
     fun markJustInitData() {
     fun markJustInitData() {
         justInitData = true
         justInitData = true

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt

@@ -60,7 +60,7 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Popup
 import androidx.compose.ui.window.Popup
 import androidx.compose.ui.window.PopupPositionProvider
 import androidx.compose.ui.window.PopupPositionProvider
 import androidx.compose.ui.window.PopupProperties
 import androidx.compose.ui.window.PopupProperties
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ProvideAppDensity
 import io.legado.app.ui.theme.ProvideAppDensity

+ 23 - 5
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -6,13 +6,16 @@ import android.graphics.Bitmap
 import android.graphics.Canvas
 import android.graphics.Canvas
 import android.graphics.Typeface
 import android.graphics.Typeface
 import android.graphics.drawable.LayerDrawable
 import android.graphics.drawable.LayerDrawable
+import android.os.Build
 import android.view.LayoutInflater
 import android.view.LayoutInflater
+import android.view.WindowInsets
 import android.widget.FrameLayout
 import android.widget.FrameLayout
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.ViewCompat
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.isGone
 import androidx.core.view.isGone
+import androidx.core.view.updateLayoutParams
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppConst.timeFormat
 import io.legado.app.constant.AppConst.timeFormat
 import io.legado.app.constant.ReadTipType
 import io.legado.app.constant.ReadTipType
@@ -29,13 +32,11 @@ import io.legado.app.ui.book.read.page.provider.TipStyleProvider
 import io.legado.app.ui.book.read.sheet.CustomTipTarget
 import io.legado.app.ui.book.read.sheet.CustomTipTarget
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.widget.BatteryView
 import io.legado.app.ui.widget.BatteryView
-import androidx.core.view.updateLayoutParams
 import io.legado.app.utils.activity
 import io.legado.app.utils.activity
 import io.legado.app.utils.applyStatusBarPadding
 import io.legado.app.utils.applyStatusBarPadding
 import io.legado.app.utils.canvasrecorder.CanvasRecorder
 import io.legado.app.utils.canvasrecorder.CanvasRecorder
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
 import io.legado.app.utils.gone
-import io.legado.app.utils.navigationBarHeight
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.statusBarHeight
 import io.legado.app.utils.statusBarHeight
 import splitties.views.backgroundColor
 import splitties.views.backgroundColor
@@ -101,9 +102,10 @@ class PageView(
         callBack?.let { binding.contentTextView.setCallBack(it) }
         callBack?.let { binding.contentTextView.setCallBack(it) }
         upStyle()
         upStyle()
         binding.vwStatusBar.applyStatusBarPadding()
         binding.vwStatusBar.applyStatusBarPadding()
-        if (ReadSessionState.lastNavigationBarHeight > 0) {
+        val initialNavigationBarHeight = currentNavigationBarHeight()
+        if (initialNavigationBarHeight > 0) {
             binding.vwNavigationBar.updateLayoutParams {
             binding.vwNavigationBar.updateLayoutParams {
-                height = ReadSessionState.lastNavigationBarHeight
+                height = initialNavigationBarHeight
             }
             }
         }
         }
         binding.vwNavigationBar.setOnApplyWindowInsetsListenerCompat { v, windowInsets ->
         binding.vwNavigationBar.setOnApplyWindowInsetsListenerCompat { v, windowInsets ->
@@ -112,7 +114,9 @@ class PageView(
                 return@setOnApplyWindowInsetsListenerCompat windowInsets
                 return@setOnApplyWindowInsetsListenerCompat windowInsets
             }
             }
             //Log.d("fansangg", "vwNavigationBar OnApplyWindowInsetsListener: navHeight=$navHeight, isImeVisible=${windowInsets.isVisible(WindowInsetsCompat.Type.ime())}, imeHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom}, systemBarsHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom}")
             //Log.d("fansangg", "vwNavigationBar OnApplyWindowInsetsListener: navHeight=$navHeight, isImeVisible=${windowInsets.isVisible(WindowInsetsCompat.Type.ime())}, imeHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.ime()).bottom}, systemBarsHeight=${windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom}")
-            val navHeight = windowInsets.navigationBarHeight
+            val navHeight = windowInsets.getInsetsIgnoringVisibility(
+                WindowInsetsCompat.Type.navigationBars()
+            ).bottom
             if (navHeight > 0) {
             if (navHeight > 0) {
                 ReadSessionState.lastNavigationBarHeight = navHeight
                 ReadSessionState.lastNavigationBarHeight = navHeight
             }
             }
@@ -123,6 +127,20 @@ class PageView(
         }
         }
     }
     }
 
 
+    private fun currentNavigationBarHeight(): Int {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+            val stableHeight = activity?.windowManager?.currentWindowMetrics?.windowInsets
+                ?.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars())
+                ?.bottom
+                ?: 0
+            if (stableHeight > 0) {
+                ReadSessionState.lastNavigationBarHeight = stableHeight
+                return stableHeight
+            }
+        }
+        return ReadSessionState.lastNavigationBarHeight
+    }
+
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
     override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
         super.onSizeChanged(w, h, oldw, oldh)
         super.onSizeChanged(w, h, oldw, oldh)
         upBg()
         upBg()

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextChapter.kt

@@ -8,9 +8,9 @@ import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.help.book.BookContent
 import io.legado.app.help.book.BookContent
+import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.read.page.provider.TextChapterLayout
 import io.legado.app.ui.book.read.page.provider.TextChapterLayout
-import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.utils.fastBinarySearchBy
 import io.legado.app.utils.fastBinarySearchBy
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
 import kotlin.math.abs
 import kotlin.math.abs
@@ -203,7 +203,7 @@ data class TextChapter(
         val stringBuilder = StringBuilder()
         val stringBuilder = StringBuilder()
         if (pages.isNotEmpty()) {
         if (pages.isNotEmpty()) {
             for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) {
             for (index in pageIndex..min(pageEndIndex, pages.lastIndex)) {
-                stringBuilder.append(pages[index].text.replace(Regex("[袮꧁]"), " "))
+                stringBuilder.append(pages[index].text.replace(Regex("[袮祢꧁]"), " "))
                 if (pageSplit && !stringBuilder.endsWith("\n")) {
                 if (pageSplit && !stringBuilder.endsWith("\n")) {
                     stringBuilder.append("\n")
                     stringBuilder.append("\n")
                 }
                 }

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

@@ -23,9 +23,9 @@ import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.DarkMode
 import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.Grid3x3
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.filled.LightMode
 import androidx.compose.material.icons.filled.LightMode
-import androidx.compose.material.icons.filled.SpaceBar
 import androidx.compose.material.icons.filled.TextFields
 import androidx.compose.material.icons.filled.TextFields
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material.icons.filled.Visibility
 import androidx.compose.material3.Icon
 import androidx.compose.material3.Icon
@@ -48,7 +48,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.toColorInt
 import androidx.core.graphics.toColorInt
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.model.settings.ReadStyleItem
@@ -371,7 +371,7 @@ fun GlobalThemePage(
             ) {
             ) {
                 Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
                 Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
                     Icon(
                     Icon(
-                        imageVector = Icons.Default.SpaceBar,
+                        imageVector = Icons.Default.Grid3x3,
                         contentDescription = stringResource(R.string.padding),
                         contentDescription = stringResource(R.string.padding),
                         tint = LegadoTheme.colorScheme.onSurfaceVariant,
                         tint = LegadoTheme.colorScheme.onSurfaceVariant,
                     )
                     )

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/sheet/TextSelectMenuConfigSheet.kt

@@ -42,7 +42,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
 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 androidx.compose.ui.unit.sp
 import androidx.compose.ui.unit.sp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.book.read.ActionMenuItem
 import io.legado.app.ui.book.read.ActionMenuItem
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme

+ 11 - 2
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -10,6 +10,14 @@ data class ReadAloudTextLineUi(
     val chapterPosition: Int,
     val chapterPosition: Int,
 )
 )
 
 
+@Stable
+data class ReadAloudChapterUi(
+    val index: Int,
+    val title: String,
+    val isVolume: Boolean,
+    val tocLevel: Int,
+)
+
 @Stable
 @Stable
 data class ReadAloudPlayerUiState(
 data class ReadAloudPlayerUiState(
     val bookUrl: String = "",
     val bookUrl: String = "",
@@ -19,6 +27,7 @@ data class ReadAloudPlayerUiState(
     val sourceOrigin: String? = null,
     val sourceOrigin: String? = null,
     val chapterIndex: Int = -1,
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
     val chapterTitle: String = "",
+    val chapters: ImmutableList<ReadAloudChapterUi> = persistentListOf(),
     val chapterText: String = "",
     val chapterText: String = "",
     val textLines: ImmutableList<ReadAloudTextLineUi> = persistentListOf(),
     val textLines: ImmutableList<ReadAloudTextLineUi> = persistentListOf(),
     val activeTextLine: Int = -1,
     val activeTextLine: Int = -1,
@@ -43,7 +52,8 @@ sealed interface ReadAloudPlayerIntent {
     data object NextParagraph : ReadAloudPlayerIntent
     data object NextParagraph : ReadAloudPlayerIntent
     data object OpenSettings : ReadAloudPlayerIntent
     data object OpenSettings : ReadAloudPlayerIntent
     data object SwitchToClassic : ReadAloudPlayerIntent
     data object SwitchToClassic : ReadAloudPlayerIntent
-    data object OpenToc : ReadAloudPlayerIntent
+    data object CycleBgMode : ReadAloudPlayerIntent
+    data class SelectChapter(val index: Int) : ReadAloudPlayerIntent
     data class SetBgMode(val value: Int) : ReadAloudPlayerIntent
     data class SetBgMode(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
@@ -51,7 +61,6 @@ sealed interface ReadAloudPlayerIntent {
 }
 }
 
 
 sealed interface ReadAloudPlayerEffect {
 sealed interface ReadAloudPlayerEffect {
-    data object OpenToc : ReadAloudPlayerEffect
     data object ReturnToReaderSettings : ReadAloudPlayerEffect
     data object ReturnToReaderSettings : ReadAloudPlayerEffect
     data object ReturnToClassic : ReadAloudPlayerEffect
     data object ReturnToClassic : ReadAloudPlayerEffect
 }
 }

+ 39 - 3
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt

@@ -4,6 +4,7 @@ import android.app.Application
 import androidx.lifecycle.Observer
 import androidx.lifecycle.Observer
 import com.jeremyliao.liveeventbus.LiveEventBus
 import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
+import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloud
@@ -13,12 +14,16 @@ import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.book.read.ReadConfigUpdateBus
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.flow.merge
 
 
@@ -27,6 +32,7 @@ class ReadAloudPlayerCoordinator(
     private val application: Application,
     private val application: Application,
     private val sessionStore: ReadAloudSessionStore,
     private val sessionStore: ReadAloudSessionStore,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
     private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val bookRepository: BookRepository,
 ) {
 ) {
     private val refreshRequests = MutableSharedFlow<Unit>(replay = 1)
     private val refreshRequests = MutableSharedFlow<Unit>(replay = 1)
     private val bookChanges = callbackFlow {
     private val bookChanges = callbackFlow {
@@ -41,10 +47,29 @@ class ReadAloudPlayerCoordinator(
     private val bookState =
     private val bookState =
         merge(bookChanges, refreshRequests, configChanges).map { snapshotBook() }
         merge(bookChanges, refreshRequests, configChanges).map { snapshotBook() }
 
 
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val bookWithChapters = bookState.flatMapLatest { book ->
+        if (book.bookUrl.isBlank()) {
+            flowOf(book to persistentListOf<ReadAloudChapterSourceState>())
+        } else {
+            bookRepository.flowChapters(book.bookUrl).map { chapters ->
+                val sourceChapters = chapters.map { chapter ->
+                    ReadAloudChapterSourceState(
+                        index = chapter.index,
+                        title = chapter.title,
+                        isVolume = chapter.isVolume,
+                        tocLevel = chapter.tocLevel,
+                    )
+                }.toImmutableList()
+                book to sourceChapters
+            }
+        }
+    }
+
     val state: Flow<ReadAloudPlayerSourceState> = combine(
     val state: Flow<ReadAloudPlayerSourceState> = combine(
         sessionStore.state,
         sessionStore.state,
-        bookState,
-    ) { session, book ->
+        bookWithChapters,
+    ) { session, (book, chapters) ->
         val playback = session.playback
         val playback = session.playback
         ReadAloudPlayerSourceState(
         ReadAloudPlayerSourceState(
             bookUrl = book.bookUrl,
             bookUrl = book.bookUrl,
@@ -54,6 +79,7 @@ class ReadAloudPlayerCoordinator(
             sourceOrigin = book.sourceOrigin,
             sourceOrigin = book.sourceOrigin,
             chapterIndex = book.chapterIndex,
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
             chapterTitle = book.chapterTitle,
+            chapters = chapters,
             chapterText = book.chapterText,
             chapterText = book.chapterText,
             textLines = book.textLines,
             textLines = book.textLines,
             chapterPosition = playback.chapterPosition,
             chapterPosition = playback.chapterPosition,
@@ -79,6 +105,7 @@ class ReadAloudPlayerCoordinator(
             sourceOrigin = book.sourceOrigin,
             sourceOrigin = book.sourceOrigin,
             chapterIndex = book.chapterIndex,
             chapterIndex = book.chapterIndex,
             chapterTitle = book.chapterTitle,
             chapterTitle = book.chapterTitle,
+            chapters = persistentListOf(),
             chapterText = book.chapterText,
             chapterText = book.chapterText,
             textLines = book.textLines,
             textLines = book.textLines,
             chapterPosition = playback.chapterPosition,
             chapterPosition = playback.chapterPosition,
@@ -109,7 +136,7 @@ class ReadAloudPlayerCoordinator(
             chapterTitle = chapter?.title.orEmpty(),
             chapterTitle = chapter?.title.orEmpty(),
             chapterText = chapter?.getContent().orEmpty(),
             chapterText = chapter?.getContent().orEmpty(),
             textLines = chapter?.paragraphs.orEmpty().mapNotNull { paragraph ->
             textLines = chapter?.paragraphs.orEmpty().mapNotNull { paragraph ->
-                paragraph.text.replace(Regex("[袮꧁]"), " ").trim()
+                paragraph.text.replace(Regex("[袮祢꧁]"), " ").trim()
                     .takeIf(String::isNotEmpty)?.let {
                     .takeIf(String::isNotEmpty)?.let {
                     ReadAloudTextLineUi(it, paragraph.chapterPosition)
                     ReadAloudTextLineUi(it, paragraph.chapterPosition)
                 }
                 }
@@ -129,6 +156,7 @@ class ReadAloudPlayerCoordinator(
     fun nextParagraph() = ReadAloud.nextParagraph(application)
     fun nextParagraph() = ReadAloud.nextParagraph(application)
     fun previousChapter() = ReadBook.moveToPrevChapter(true, false)
     fun previousChapter() = ReadBook.moveToPrevChapter(true, false)
     fun nextChapter() = ReadBook.moveToNextChapter(true)
     fun nextChapter() = ReadBook.moveToNextChapter(true)
+    fun selectChapter(index: Int) = ReadBook.openChapter(index, durChapterPos = 0)
 
 
     suspend fun setSpeed(value: Int) {
     suspend fun setSpeed(value: Int) {
         readAloudSettingsGateway.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }
         readAloudSettingsGateway.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }
@@ -168,6 +196,13 @@ class ReadAloudPlayerCoordinator(
     )
     )
 }
 }
 
 
+data class ReadAloudChapterSourceState(
+    val index: Int,
+    val title: String,
+    val isVolume: Boolean,
+    val tocLevel: Int,
+)
+
 data class ReadAloudPlayerSourceState(
 data class ReadAloudPlayerSourceState(
     val bookUrl: String,
     val bookUrl: String,
     val bookName: String,
     val bookName: String,
@@ -176,6 +211,7 @@ data class ReadAloudPlayerSourceState(
     val sourceOrigin: String?,
     val sourceOrigin: String?,
     val chapterIndex: Int,
     val chapterIndex: Int,
     val chapterTitle: String,
     val chapterTitle: String,
+    val chapters: ImmutableList<ReadAloudChapterSourceState>,
     val chapterText: String,
     val chapterText: String,
     val textLines: ImmutableList<ReadAloudTextLineUi>,
     val textLines: ImmutableList<ReadAloudTextLineUi>,
     val chapterPosition: Int,
     val chapterPosition: Int,

+ 274 - 24
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -14,6 +14,7 @@ import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.Canvas
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
@@ -33,15 +34,18 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.MenuBook
+import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.FastForward
 import androidx.compose.material.icons.filled.FastRewind
 import androidx.compose.material.icons.filled.FastRewind
+import androidx.compose.material.icons.filled.KeyboardArrowUp
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.material.icons.filled.Settings
@@ -50,7 +54,7 @@ import androidx.compose.material.icons.filled.SkipPrevious
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.Tune
 import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material3.Slider
+import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -96,14 +100,18 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
 import io.legado.app.ui.util.rememberBlurBackdrop
 import io.legado.app.ui.util.rememberBlurBackdrop
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppSlider
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.effect.BgEffectBackground
 import io.legado.app.ui.widget.components.effect.BgEffectBackground
 import io.legado.app.ui.widget.components.effect.BgEffectConfig
 import io.legado.app.ui.widget.components.effect.BgEffectConfig
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
 import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
+import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.first
@@ -116,6 +124,7 @@ import top.yukonga.miuix.kmp.blur.layerBackdrop
 import top.yukonga.miuix.kmp.blur.textureBlur
 import top.yukonga.miuix.kmp.blur.textureBlur
 import kotlin.math.abs
 import kotlin.math.abs
 import kotlin.math.roundToInt
 import kotlin.math.roundToInt
+import kotlin.time.Duration.Companion.milliseconds
 import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode
 import androidx.compose.ui.graphics.BlendMode as ComposeBlendMode
 
 
 @OptIn(ExperimentalHazeMaterialsApi::class)
 @OptIn(ExperimentalHazeMaterialsApi::class)
@@ -125,7 +134,13 @@ fun ReadAloudPlayerScreenContent(
     onIntent: (ReadAloudPlayerIntent) -> Unit,
     onIntent: (ReadAloudPlayerIntent) -> Unit,
     onBack: () -> Unit,
     onBack: () -> Unit,
 ) {
 ) {
-    val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val horizontalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val verticalPagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
+    val verticalPagerNestedScrollConnection = rememberPagerFlingPassThroughConnection(
+        state = verticalPagerState,
+        orientation = Orientation.Vertical,
+    )
+    val coroutineScope = rememberCoroutineScope()
     var activeAdjustment by remember { mutableStateOf<PlayerAdjustment?>(null) }
     var activeAdjustment by remember { mutableStateOf<PlayerAdjustment?>(null) }
     var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed.toFloat()) }
     var speedPreview by remember(state.speed) { mutableFloatStateOf(state.speed.toFloat()) }
     var timerPreview by remember(state.timerMinutes) {
     var timerPreview by remember(state.timerMinutes) {
@@ -148,8 +163,8 @@ fun ReadAloudPlayerScreenContent(
     } else {
     } else {
         Modifier
         Modifier
     }
     }
-    LaunchedEffect(pagerState.currentPage) {
-        if (pagerState.currentPage != 1) isTextPageUserScrolling = false
+    LaunchedEffect(horizontalPagerState.currentPage) {
+        if (horizontalPagerState.currentPage != 1) isTextPageUserScrolling = false
     }
     }
     val pageContentPadding = PaddingValues(
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
@@ -259,8 +274,16 @@ fun ReadAloudPlayerScreenContent(
                             .padding(horizontal = 24.dp),
                             .padding(horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceBetween
                         horizontalArrangement = Arrangement.SpaceBetween
                     ) {
                     ) {
-                        AppText(formatPosition(state.chapterPosition), style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant)
-                        AppText("${(state.chapterPosition * 100 / state.chapterLength.coerceAtLeast(1))}%", style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant)
+                        AppText(
+                            formatPosition(state.chapterPosition),
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                        AppText(
+                            "${(state.chapterPosition * 100 / state.chapterLength.coerceAtLeast(1))}%",
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
                     }
                     }
 
 
                     Row(
                     Row(
@@ -308,9 +331,29 @@ fun ReadAloudPlayerScreenContent(
                         horizontalArrangement = Arrangement.SpaceEvenly,
                         horizontalArrangement = Arrangement.SpaceEvenly,
                     ) {
                     ) {
                         SmallPlainButton(
                         SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.OpenToc) },
-                            icon = Icons.AutoMirrored.Filled.MenuBook,
-                            contentDescription = stringResource(R.string.chapter_list),
+                            onClick = {
+                                coroutineScope.launch {
+                                    verticalPagerState.animateScrollToPage(
+                                        page = if (verticalPagerState.currentPage == 0) 1 else 0,
+                                        animationSpec = tween(
+                                            durationMillis = 520,
+                                            easing = FastOutSlowInEasing,
+                                        ),
+                                    )
+                                }
+                            },
+                            icon = if (verticalPagerState.currentPage == 0) {
+                                Icons.AutoMirrored.Filled.FormatListBulleted
+                            } else {
+                                Icons.Default.KeyboardArrowUp
+                            },
+                            contentDescription = stringResource(
+                                if (verticalPagerState.currentPage == 0) {
+                                    R.string.chapter_list
+                                } else {
+                                    R.string.back
+                                }
+                            ),
                         )
                         )
                         SmallPlainButton(
                         SmallPlainButton(
                             onClick = { onIntent(ReadAloudPlayerIntent.SwitchToClassic) },
                             onClick = { onIntent(ReadAloudPlayerIntent.SwitchToClassic) },
@@ -318,9 +361,9 @@ fun ReadAloudPlayerScreenContent(
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
                         )
                         )
                         SmallPlainButton(
                         SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.OpenSettings) },
-                            icon = Icons.Default.Settings,
-                            contentDescription = stringResource(R.string.setting),
+                            onClick = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
+                            icon = Icons.Default.WbTwilight,
+                            contentDescription = bgModeLabel(state.bgMode)
                         )
                         )
                         SmallPlainButton(
                         SmallPlainButton(
                             onClick = {
                             onClick = {
@@ -340,31 +383,43 @@ fun ReadAloudPlayerScreenContent(
                         )
                         )
                     }
                     }
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Speed) {
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Speed) {
-                        Slider(
+                        PlayerAdjustmentSlider(
+                            title = stringResource(R.string.read_aloud_adjust_speed),
                             value = speedPreview.coerceIn(5f, 20f),
                             value = speedPreview.coerceIn(5f, 20f),
+                            valueLabel = (speedPreview / 10f).let {
+                                if (it == it.roundToInt().toFloat()) {
+                                    "${it.roundToInt()}.0"
+                                } else {
+                                    String.format("%.1f", it)
+                                }
+                            },
+                            startLabel = stringResource(R.string.fast_rewind),
+                            endLabel = stringResource(R.string.fast_forward),
                             onValueChange = { speedPreview = it },
                             onValueChange = { speedPreview = it },
                             onValueChangeFinished = {
                             onValueChangeFinished = {
                                 onIntent(ReadAloudPlayerIntent.SetSpeed(speedPreview.roundToInt()))
                                 onIntent(ReadAloudPlayerIntent.SetSpeed(speedPreview.roundToInt()))
                             },
                             },
                             valueRange = 5f..20f,
                             valueRange = 5f..20f,
                             steps = 14,
                             steps = 14,
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(horizontal = 16.dp),
                         )
                         )
                     }
                     }
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Timer) {
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Timer) {
-                        Slider(
+                        PlayerAdjustmentSlider(
+                            title = stringResource(R.string.set_timer),
                             value = timerPreview.coerceIn(0f, 180f),
                             value = timerPreview.coerceIn(0f, 180f),
+                            valueLabel = if (timerPreview == 0f) {
+                                stringResource(R.string.close)
+                            } else {
+                                stringResource(R.string.timer_m, timerPreview.roundToInt())
+                            },
+                            startLabel = stringResource(R.string.close),
+                            endLabel = stringResource(R.string.timer_m, 180),
                             onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
                             onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
                             onValueChangeFinished = {
                             onValueChangeFinished = {
                                 onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
                                 onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
                             },
                             },
                             valueRange = 0f..180f,
                             valueRange = 0f..180f,
                             steps = 17,
                             steps = 17,
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(horizontal = 16.dp),
                         )
                         )
                     }
                     }
                 }
                 }
@@ -384,12 +439,27 @@ fun ReadAloudPlayerScreenContent(
                 },
                 },
             )
             )
             HorizontalPager(
             HorizontalPager(
-                state = pagerState,
+                state = horizontalPagerState,
                 modifier = Modifier.fillMaxSize(),
                 modifier = Modifier.fillMaxSize(),
                 verticalAlignment = Alignment.CenterVertically,
                 verticalAlignment = Alignment.CenterVertically,
             ) { page ->
             ) { page ->
                 if (page == 0) {
                 if (page == 0) {
-                    CoverPage(state, pageContentPadding, flowingTextModifier)
+                    VerticalPager(
+                        state = verticalPagerState,
+                        modifier = Modifier.fillMaxSize(),
+                        horizontalAlignment = Alignment.CenterHorizontally,
+                        pageNestedScrollConnection = verticalPagerNestedScrollConnection,
+                    ) { verticalPage ->
+                        if (verticalPage == 0) {
+                            CoverPage(state, pageContentPadding, flowingTextModifier)
+                        } else {
+                            ChapterTocPage(
+                                state = state,
+                                contentPadding = pageContentPadding,
+                                onIntent = onIntent,
+                            )
+                        }
+                    }
                 } else {
                 } else {
                     ChapterTextPage(
                     ChapterTextPage(
                         state = state,
                         state = state,
@@ -403,6 +473,177 @@ fun ReadAloudPlayerScreenContent(
         }
         }
     }
     }
 }
 }
+
+@Composable
+private fun PlayerAdjustmentSlider(
+    title: String,
+    value: Float,
+    valueLabel: String,
+    startLabel: String,
+    endLabel: String,
+    onValueChange: (Float) -> Unit,
+    onValueChangeFinished: () -> Unit,
+    valueRange: ClosedFloatingPointRange<Float>,
+    steps: Int,
+) {
+    NormalCard(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        cornerRadius = 28.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            Row(
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                AppText(
+                    modifier = Modifier.weight(1f),
+                    text = title,
+                    style = LegadoTheme.typography.labelMediumEmphasized,
+                )
+                TextCard(
+                    text = valueLabel,
+                )
+            }
+
+            AppSlider(
+                value = value,
+                onValueChange = onValueChange,
+                onValueChangeFinished = onValueChangeFinished,
+                valueRange = valueRange,
+                steps = steps,
+                modifier = Modifier.fillMaxWidth(),
+            )
+
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween,
+            ) {
+                AppText(
+                    text = startLabel,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                AppText(
+                    text = endLabel,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ChapterTocPage(
+    state: ReadAloudPlayerUiState,
+    contentPadding: PaddingValues,
+    onIntent: (ReadAloudPlayerIntent) -> Unit,
+) {
+    val listState = rememberLazyListState()
+    LaunchedEffect(state.chapterIndex, state.chapters) {
+        val currentItem = state.chapters.indexOfFirst {
+            !it.isVolume && it.index == state.chapterIndex
+        }
+        if (currentItem >= 0) {
+            listState.scrollToItem((currentItem - 2).coerceAtLeast(0))
+        }
+    }
+
+    LazyColumn(
+        state = listState,
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(
+            start = 16.dp,
+            top = contentPadding.calculateTopPadding() + 12.dp,
+            end = 16.dp,
+            bottom = contentPadding.calculateBottomPadding() + 12.dp,
+        ),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        items(
+            items = state.chapters,
+            key = { chapter ->
+                if (chapter.isVolume) "player-volume-${chapter.index}"
+                else "player-chapter-${chapter.index}"
+            },
+            contentType = { chapter -> if (chapter.isVolume) "volume" else "chapter" },
+        ) { chapter ->
+            if (chapter.isVolume) {
+                AppText(
+                    text = chapter.title,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(
+                            start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp,
+                            top = 12.dp,
+                            bottom = 4.dp,
+                        ),
+                    style = LegadoTheme.typography.titleSmallEmphasized,
+                    color = LegadoTheme.colorScheme.primary,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            } else {
+                val isCurrent = chapter.index == state.chapterIndex
+                NormalCard(
+                    onClick = { onIntent(ReadAloudPlayerIntent.SelectChapter(chapter.index)) },
+                    cornerRadius = 12.dp,
+                    containerColor = if (isCurrent) {
+                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+                    } else {
+                        LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0f)
+                    },
+                    contentColor = if (isCurrent) {
+                        LegadoTheme.colorScheme.onSurface
+                    } else {
+                        LegadoTheme.colorScheme.onSurface
+                    },
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(start = (chapter.tocLevel.coerceIn(0, 6) * 10).dp),
+                ) {
+                    Row(
+                        modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
+                        verticalAlignment = Alignment.CenterVertically,
+                    ) {
+                        if (isCurrent) {
+                            AppIcon(
+                                imageVector = if (state.isPaused) {
+                                    Icons.Default.PlayArrow
+                                } else {
+                                    Icons.Default.Pause
+                                },
+                                contentDescription = null,
+                                modifier = Modifier
+                                    .padding(end = 8.dp)
+                                    .size(16.dp),
+                            )
+                        }
+                        Column(modifier = Modifier.weight(1f)) {
+                            AppText(
+                                text = chapter.title,
+                                style = if (isCurrent) {
+                                    LegadoTheme.typography.bodyMediumEmphasized
+                                } else {
+                                    LegadoTheme.typography.bodyMedium
+                                },
+                                maxLines = 2,
+                                overflow = TextOverflow.Ellipsis,
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
 @Composable
 @Composable
 private fun PlayerProgressSlider(
 private fun PlayerProgressSlider(
     value: Float,
     value: Float,
@@ -615,7 +856,7 @@ private fun ChapterTextPage(
             override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
             override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
                 val releaseGeneration = userScrollGeneration.intValue
                 val releaseGeneration = userScrollGeneration.intValue
                 coroutineScope.launch {
                 coroutineScope.launch {
-                    delay(BOTTOM_BAR_RESTORE_DELAY_MILLIS)
+                    delay(BOTTOM_BAR_RESTORE_DELAY_MILLIS.milliseconds)
                     if (userScrollGeneration.intValue == releaseGeneration) {
                     if (userScrollGeneration.intValue == releaseGeneration) {
                         currentOnUserScrollChanged(false)
                         currentOnUserScrollChanged(false)
                     }
                     }
@@ -879,3 +1120,12 @@ private fun flowingTextBlend(): List<BlendColorEntry> {
         }
         }
     }
     }
 }
 }
+
+@Composable
+private fun bgModeLabel(mode: Int): String = when (mode) {
+    ReadAloudBgMode.Solid -> stringResource(R.string.read_aloud_bg_solid)
+    ReadAloudBgMode.Blur -> stringResource(R.string.read_aloud_bg_blur)
+    ReadAloudBgMode.FlowingLight -> stringResource(R.string.read_aloud_bg_flowing_light)
+    ReadAloudBgMode.Transparent -> stringResource(R.string.read_aloud_bg_transparent)
+    else -> stringResource(R.string.read_aloud_bg_blur)
+}

+ 23 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -6,6 +6,7 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsInt
 import io.legado.app.help.config.compatDsInt
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -41,7 +42,8 @@ class ReadAloudPlayerViewModel(
             ReadAloudPlayerIntent.NextChapter -> coordinator.nextChapter()
             ReadAloudPlayerIntent.NextChapter -> coordinator.nextChapter()
             ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
-            ReadAloudPlayerIntent.OpenToc -> effect(ReadAloudPlayerEffect.OpenToc)
+            ReadAloudPlayerIntent.CycleBgMode -> cycleBgMode()
+            is ReadAloudPlayerIntent.SelectChapter -> coordinator.selectChapter(intent.index)
             is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
             is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
                 PreferKey.readAloudPlayerBgMode,
                 PreferKey.readAloudPlayerBgMode,
                 intent.value,
                 intent.value,
@@ -57,6 +59,16 @@ class ReadAloudPlayerViewModel(
         }
         }
     }
     }
 
 
+    private fun cycleBgMode() {
+        val next = when (readBgMode()) {
+            ReadAloudBgMode.Solid -> ReadAloudBgMode.Blur
+            ReadAloudBgMode.Blur -> ReadAloudBgMode.FlowingLight
+            ReadAloudBgMode.FlowingLight -> ReadAloudBgMode.Transparent
+            else -> ReadAloudBgMode.Blur
+        }
+        AppConfigStore.putInt(PreferKey.readAloudPlayerBgMode, next)
+    }
+
     private fun toUiState(
     private fun toUiState(
         source: ReadAloudPlayerSourceState,
         source: ReadAloudPlayerSourceState,
         bgMode: Int,
         bgMode: Int,
@@ -64,6 +76,14 @@ class ReadAloudPlayerViewModel(
         val activeIndex = source.textLines.indexOfLast {
         val activeIndex = source.textLines.indexOfLast {
             it.chapterPosition <= source.chapterPosition
             it.chapterPosition <= source.chapterPosition
         }
         }
+        val chapters = source.chapters.map { chapter ->
+            ReadAloudChapterUi(
+                index = chapter.index,
+                title = chapter.title,
+                isVolume = chapter.isVolume,
+                tocLevel = chapter.tocLevel,
+            )
+        }.toImmutableList()
         return ReadAloudPlayerUiState(
         return ReadAloudPlayerUiState(
             bookUrl = source.bookUrl,
             bookUrl = source.bookUrl,
             bookName = source.bookName,
             bookName = source.bookName,
@@ -72,6 +92,7 @@ class ReadAloudPlayerViewModel(
             sourceOrigin = source.sourceOrigin,
             sourceOrigin = source.sourceOrigin,
             chapterIndex = source.chapterIndex,
             chapterIndex = source.chapterIndex,
             chapterTitle = source.chapterTitle,
             chapterTitle = source.chapterTitle,
+            chapters = chapters,
             chapterText = source.chapterText,
             chapterText = source.chapterText,
             textLines = source.textLines,
             textLines = source.textLines,
             activeTextLine = activeIndex,
             activeTextLine = activeIndex,
@@ -96,4 +117,5 @@ class ReadAloudPlayerViewModel(
         return AppConfigStore.preferences.compatDsInt(PreferKey.readAloudPlayerBgMode)
         return AppConfigStore.preferences.compatDsInt(PreferKey.readAloudPlayerBgMode)
             ?: ReadAloudBgMode.Blur
             ?: ReadAloudBgMode.Blur
     }
     }
+
 }
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditRoute.kt

@@ -75,9 +75,9 @@ fun BookSourceEditRoute(
         text = stringResource(R.string.exit_no_save),
         text = stringResource(R.string.exit_no_save),
         confirmText = stringResource(R.string.yes),
         confirmText = stringResource(R.string.yes),
         onConfirm = {
         onConfirm = {
-            viewModel.onIntent(BookSourceEditIntent.DiscardChanges)
+            viewModel.onIntent(BookSourceEditIntent.DismissDialog)
         },
         },
         dismissText = stringResource(R.string.no),
         dismissText = stringResource(R.string.no),
-        onDismiss = { viewModel.onIntent(BookSourceEditIntent.DismissDialog) },
+        onDismiss = { viewModel.onIntent(BookSourceEditIntent.DiscardChanges) },
     )
     )
 }
 }

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceContract.kt

@@ -109,6 +109,13 @@ sealed interface BookSourceIntent {
 }
 }
 
 
 sealed interface BookSourceEffect {
 sealed interface BookSourceEffect {
+    data class StartCheck(
+        val ids: Set<String>,
+        val keyword: String,
+    ) : BookSourceEffect
+
+    data object CancelCheck : BookSourceEffect
+
     data class ShowSnackbar(
     data class ShowSnackbar(
         val message: String,
         val message: String,
         val actionLabel: String? = null,
         val actionLabel: String? = null,

+ 62 - 35
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.service.BookSourceCheckService
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.ActionItem
 import io.legado.app.ui.widget.components.ActionItem
@@ -75,7 +76,6 @@ import io.legado.app.ui.widget.components.rules.RuleListScaffold
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 import sh.calvin.reorderable.rememberReorderableLazyListState
 import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -92,16 +92,43 @@ fun BookSourceRouteScreen(
     onDebugSource: (String) -> Unit,
     onDebugSource: (String) -> Unit,
 ) {
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
+    val snackbarHostState = remember { SnackbarHostState() }
+    val clipboardManager = LocalClipboard.current
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is BookSourceEffect.StartCheck -> {
+                    BookSourceCheckService.start(context, effect.ids, effect.keyword)
+                }
+
+                BookSourceEffect.CancelCheck -> BookSourceCheckService.stop(context)
+
+                is BookSourceEffect.ShowSnackbar -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = effect.message,
+                        actionLabel = effect.actionLabel,
+                        withDismissAction = true,
+                    )
+                    if (result == SnackbarResult.ActionPerformed && effect.url != null) {
+                        clipboardManager.setClipEntry(
+                            ClipEntry(ClipData.newPlainText("url", effect.url))
+                        )
+                    }
+                }
+            }
+        }
+    }
     BookSourceScreen(
     BookSourceScreen(
-        state,
-        viewModel::onIntent,
-        onBackClick,
-        onAddSource,
-        onEditSource,
-        onLoginSource,
-        onSearchSource,
-        onDebugSource,
-        viewModel.effects,
+        state = state,
+        onIntent = viewModel::onIntent,
+        snackbarHostState = snackbarHostState,
+        onBackClick = onBackClick,
+        onAddSource = onAddSource,
+        onEditSource = onEditSource,
+        onLoginSource = onLoginSource,
+        onSearchSource = onSearchSource,
+        onDebugSource = onDebugSource,
     )
     )
 }
 }
 
 
@@ -110,13 +137,13 @@ fun BookSourceRouteScreen(
 fun BookSourceScreen(
 fun BookSourceScreen(
     state: BookSourceUiState,
     state: BookSourceUiState,
     onIntent: (BookSourceIntent) -> Unit,
     onIntent: (BookSourceIntent) -> Unit,
+    snackbarHostState: SnackbarHostState,
     onBackClick: () -> Unit,
     onBackClick: () -> Unit,
     onAddSource: () -> Unit,
     onAddSource: () -> Unit,
     onEditSource: (String) -> Unit,
     onEditSource: (String) -> Unit,
     onLoginSource: (String) -> Unit,
     onLoginSource: (String) -> Unit,
     onSearchSource: (String, String) -> Unit,
     onSearchSource: (String, String) -> Unit,
     onDebugSource: (String) -> Unit,
     onDebugSource: (String) -> Unit,
-    effects: Flow<BookSourceEffect>,
 ) {
 ) {
     val context = LocalContext.current
     val context = LocalContext.current
     val rules = state.items
     val rules = state.items
@@ -157,27 +184,7 @@ fun BookSourceScreen(
             }
             }
         }
         }
     }
     }
-    val snackbarHostState = remember { SnackbarHostState() }
-    val clipboardManager = LocalClipboard.current
     val cancelLabel = stringResource(R.string.cancel)
     val cancelLabel = stringResource(R.string.cancel)
-    LaunchedEffect(Unit) {
-        effects.collectLatest { effect ->
-            when (effect) {
-                is BookSourceEffect.ShowSnackbar -> {
-                    val result = snackbarHostState.showSnackbar(
-                        message = effect.message,
-                        actionLabel = effect.actionLabel,
-                        withDismissAction = true,
-                    )
-                    if (result == SnackbarResult.ActionPerformed && effect.url != null) {
-                        clipboardManager.setClipEntry(
-                            ClipEntry(ClipData.newPlainText("url", effect.url))
-                        )
-                    }
-                }
-            }
-        }
-    }
     LaunchedEffect(state.checkProgress) {
     LaunchedEffect(state.checkProgress) {
         val progress = state.checkProgress ?: return@LaunchedEffect
         val progress = state.checkProgress ?: return@LaunchedEffect
         val result = snackbarHostState.showSnackbar(
         val result = snackbarHostState.showSnackbar(
@@ -607,10 +614,30 @@ fun BookSourceScreen(
                                 }
                                 }
                             },
                             },
                             title = item.name,
                             title = item.name,
-                            subtitle = listOfNotNull(
-                                item.group,
-                                item.checkMessage
-                            ).joinToString(" · ").ifBlank { null },
+                            supportingContent = if (
+                                item.group != null || item.checkMessage != null
+                            ) {
+                                {
+                                    Column {
+                                        item.group?.let { group ->
+                                            AppText(
+                                                text = group,
+                                                style = LegadoTheme.typography.bodySmall,
+                                                maxLines = 1,
+                                                overflow = TextOverflow.Ellipsis,
+                                            )
+                                        }
+                                        item.checkMessage?.let { message ->
+                                            AppText(
+                                                text = message,
+                                                style = LegadoTheme.typography.bodySmall,
+                                            )
+                                        }
+                                    }
+                                }
+                            } else {
+                                null
+                            },
                             isEnabled = item.enabled,
                             isEnabled = item.enabled,
                             isSelected = item.id in selectedIds,
                             isSelected = item.id in selectedIds,
                             canReorder = canReorder,
                             canReorder = canReorder,

+ 75 - 10
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt

@@ -11,11 +11,13 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.BookSourceRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.gateway.BookSourceCheckFailure
 import io.legado.app.domain.gateway.BookSourceCheckGateway
 import io.legado.app.domain.gateway.BookSourceCheckGateway
+import io.legado.app.domain.gateway.BookSourceCheckResult
+import io.legado.app.domain.gateway.BookSourceCheckStatus
 import io.legado.app.domain.gateway.CheckSourceSettings
 import io.legado.app.domain.gateway.CheckSourceSettings
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.usecase.StartBookSourceCheckUseCase
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.decompressed
 import io.legado.app.help.http.newCallResponseBody
 import io.legado.app.help.http.newCallResponseBody
@@ -36,7 +38,6 @@ import io.legado.app.utils.splitNotBlank
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
-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.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
@@ -55,7 +56,6 @@ class BookSourceViewModel(
     private val otherSettingsGateway: OtherSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
     private val checkGateway: BookSourceCheckGateway,
     private val checkGateway: BookSourceCheckGateway,
     private val checkSettingsGateway: CheckSourceSettingsGateway,
     private val checkSettingsGateway: CheckSourceSettingsGateway,
-    private val startBookSourceCheck: StartBookSourceCheckUseCase,
 ) : ViewModel() {
 ) : ViewModel() {
     companion object {
     companion object {
         const val FILTER_ENABLED = "@enabled"
         const val FILTER_ENABLED = "@enabled"
@@ -80,14 +80,27 @@ class BookSourceViewModel(
         MutableStateFlow<BaseImportUiState<BookSource>>(BaseImportUiState.Idle)
         MutableStateFlow<BaseImportUiState<BookSource>>(BaseImportUiState.Idle)
     private val _effects = MutableSharedFlow<BookSourceEffect>(extraBufferCapacity = 16)
     private val _effects = MutableSharedFlow<BookSourceEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
-    private var checkJob: Job? = null
 
 
     init {
     init {
         viewModelScope.launch {
         viewModelScope.launch {
             var wasRunning = false
             var wasRunning = false
             checkGateway.state.collect { state ->
             checkGateway.state.collect { state ->
                 if (wasRunning && !state.isRunning) {
                 if (wasRunning && !state.isRunning) {
-                    _effects.tryEmit(BookSourceEffect.ShowSnackbar("书源校验完成"))
+                    val message = if (state.cancelledCount > 0) {
+                        application.getString(
+                            io.legado.app.R.string.book_source_check_cancelled,
+                            state.succeededCount,
+                            state.failedCount,
+                            state.cancelledCount,
+                        )
+                    } else {
+                        application.getString(
+                            io.legado.app.R.string.book_source_check_completed,
+                            state.succeededCount,
+                            state.failedCount,
+                        )
+                    }
+                    _effects.tryEmit(BookSourceEffect.ShowSnackbar(message))
                 }
                 }
                 wasRunning = state.isRunning
                 wasRunning = state.isRunning
             }
             }
@@ -160,7 +173,11 @@ class BookSourceViewModel(
         checkSettingsGateway.settings,
         checkSettingsGateway.settings,
     ) { state, importing, check, settings ->
     ) { state, importing, check, settings ->
         state.copy(
         state.copy(
-            items = state.items.map { it.copy(checkMessage = check.results[it.id]) }
+            items = state.items.map { item ->
+                item.copy(
+                    checkMessage = check.results[item.id]?.displayMessage(application)
+                )
+            }
                 .toImmutableList(),
                 .toImmutableList(),
             importState = importing,
             importState = importing,
             checkProgress = if (check.isRunning) application.getString(
             checkProgress = if (check.isRunning) application.getString(
@@ -229,10 +246,16 @@ class BookSourceViewModel(
             is BookSourceIntent.DeleteGroup -> updateGroup(intent.group, "")
             is BookSourceIntent.DeleteGroup -> updateGroup(intent.group, "")
             is BookSourceIntent.CheckSelectedInterval -> checkInterval(intent.ids)
             is BookSourceIntent.CheckSelectedInterval -> checkInterval(intent.ids)
             is BookSourceIntent.StartCheck -> {
             is BookSourceIntent.StartCheck -> {
-                if (checkJob?.isActive != true) {
-                    checkJob = viewModelScope.launch {
+                if (checkGateway.state.value.isRunning) {
+                    _effects.tryEmit(
+                        BookSourceEffect.ShowSnackbar(
+                            application.getString(io.legado.app.R.string.source_already_checking)
+                        )
+                    )
+                } else {
+                    viewModelScope.launch {
                         checkSettingsGateway.update(intent.options.toSettings())
                         checkSettingsGateway.update(intent.options.toSettings())
-                        startBookSourceCheck(intent.ids, intent.keyword)
+                        _effects.emit(BookSourceEffect.StartCheck(intent.ids, intent.keyword))
                     }
                     }
                 }
                 }
             }
             }
@@ -241,7 +264,7 @@ class BookSourceViewModel(
                 checkSettingsGateway.update(intent.options.toSettings())
                 checkSettingsGateway.update(intent.options.toSettings())
             }
             }
 
 
-            BookSourceIntent.CancelCheck -> checkJob?.cancel()
+            BookSourceIntent.CancelCheck -> _effects.tryEmit(BookSourceEffect.CancelCheck)
             is BookSourceIntent.Import -> importSources(intent.text)
             is BookSourceIntent.Import -> importSources(intent.text)
             is BookSourceIntent.Export -> exportSources(intent.uri, intent.ids)
             is BookSourceIntent.Export -> exportSources(intent.uri, intent.ids)
             is BookSourceIntent.Upload -> uploadSources(intent.ids)
             is BookSourceIntent.Upload -> uploadSources(intent.ids)
@@ -541,6 +564,48 @@ private fun BookSourceCheckOptionsUi.toSettings() = CheckSourceSettings(
     checkContent = checkContent,
     checkContent = checkContent,
 )
 )
 
 
+private fun BookSourceCheckResult.displayMessage(application: Application): String {
+    val errorDetail = detail ?: application.getString(io.legado.app.R.string.unknown_error)
+    return when (status) {
+        BookSourceCheckStatus.Pending -> application.getString(
+            io.legado.app.R.string.book_source_check_waiting
+        )
+
+        BookSourceCheckStatus.Running -> application.getString(
+            io.legado.app.R.string.book_source_check_running
+        )
+
+        BookSourceCheckStatus.Succeeded -> application.getString(
+            io.legado.app.R.string.book_source_check_succeeded
+        )
+
+        BookSourceCheckStatus.Cancelled -> application.getString(
+            io.legado.app.R.string.book_source_check_item_cancelled
+        )
+
+        BookSourceCheckStatus.Failed -> when (failure) {
+            BookSourceCheckFailure.SourceMissing -> application.getString(
+                io.legado.app.R.string.book_source_check_source_missing
+            )
+
+            BookSourceCheckFailure.SaveFailed -> application.getString(
+                io.legado.app.R.string.book_source_check_save_failed,
+                errorDetail,
+            )
+
+            BookSourceCheckFailure.Incomplete -> application.getString(
+                io.legado.app.R.string.book_source_check_incomplete
+            )
+
+            BookSourceCheckFailure.CheckFailed,
+            null -> application.getString(
+                io.legado.app.R.string.book_source_check_failed,
+                errorDetail,
+            )
+        }
+    }
+}
+
 private fun List<BookSourcePart>.filterFor(filter: String?, query: String): List<BookSourcePart> =
 private fun List<BookSourcePart>.filterFor(filter: String?, query: String): List<BookSourcePart> =
     filter { source ->
     filter { source ->
         val filterMatch = when (filter) {
         val filterMatch = when (filter) {

+ 0 - 319
app/src/main/java/io/legado/app/ui/browser/WebViewActivity.kt

@@ -1,319 +0,0 @@
-package io.legado.app.ui.browser
-
-import android.annotation.SuppressLint
-import android.content.pm.ActivityInfo
-import android.net.Uri
-import android.net.http.SslError
-import android.os.Bundle
-import android.view.Menu
-import android.view.MenuItem
-import android.view.View
-import android.webkit.CookieManager
-import android.webkit.SslErrorHandler
-import android.webkit.URLUtil
-import android.webkit.WebChromeClient
-import android.webkit.WebResourceRequest
-import android.webkit.WebSettings
-import android.webkit.WebView
-import android.webkit.WebViewClient
-import androidx.activity.addCallback
-import org.koin.androidx.viewmodel.ext.android.viewModel
-import androidx.core.view.size
-import io.legado.app.R
-import io.legado.app.base.VMBaseActivity
-import io.legado.app.constant.AppConst
-import io.legado.app.databinding.ActivityWebViewBinding
-import io.legado.app.help.config.AppConfig
-import io.legado.app.help.http.CookieStore
-import io.legado.app.help.source.SourceVerificationHelp
-import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.lib.dialogs.alert
-import io.legado.app.lib.dialogs.selector
-//import io.legado.app.lib.theme.accentColor
-import io.legado.app.model.Download
-import io.legado.app.ui.association.OnLineImportActivity
-import io.legado.app.utils.gone
-import io.legado.app.utils.invisible
-import io.legado.app.utils.keepScreenOn
-import io.legado.app.utils.longSnackbar
-import io.legado.app.utils.openUrl
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.setDarkeningAllowed
-import io.legado.app.utils.startActivity
-import io.legado.app.utils.toggleSystemBar
-import io.legado.app.utils.viewbindingdelegate.viewBinding
-import io.legado.app.utils.visible
-import java.net.URLDecoder
-import io.legado.app.help.http.CookieManager as AppCookieManager
-
-class WebViewActivity : VMBaseActivity<ActivityWebViewBinding, WebViewModel>() {
-
-    override val binding by viewBinding(ActivityWebViewBinding::inflate)
-    override val viewModel by viewModel<WebViewModel>()
-    private var customWebViewCallback: WebChromeClient.CustomViewCallback? = null
-    private var webPic: String? = null
-    private var isCloudflareChallenge = false
-    private var isFullScreen = false
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        binding.titleBar.title = intent.getStringExtra("title") ?: getString(R.string.loading)
-        binding.titleBar.subtitle = intent.getStringExtra("sourceName")
-        viewModel.initData(intent) {
-            val url = viewModel.baseUrl
-            val headerMap = viewModel.headerMap
-            initWebView(url, headerMap)
-            val html = viewModel.html
-            if (html.isNullOrEmpty()) {
-                binding.webView.loadUrl(url, headerMap)
-            } else {
-                binding.webView.loadDataWithBaseURL(url, html, "text/html", "utf-8", url)
-            }
-        }
-        onBackPressedDispatcher.addCallback(this) {
-            if (binding.customWebView.size > 0) {
-                customWebViewCallback?.onCustomViewHidden()
-                return@addCallback
-            } else if (binding.webView.canGoBack()
-                && binding.webView.copyBackForwardList().size > 1
-            ) {
-                binding.webView.goBack()
-                return@addCallback
-            }
-            if (isFullScreen) {
-                toggleFullScreen()
-                return@addCallback
-            }
-            finish()
-        }
-    }
-
-    override fun onCompatCreateOptionsMenu(menu: Menu): Boolean {
-        menuInflater.inflate(R.menu.web_view, menu)
-        return super.onCompatCreateOptionsMenu(menu)
-    }
-
-    override fun onPrepareOptionsMenu(menu: Menu): Boolean {
-        if (viewModel.sourceOrigin.isNotEmpty()) {
-            menu.findItem(R.id.menu_disable_source)?.isVisible = true
-            menu.findItem(R.id.menu_delete_source)?.isVisible = true
-        }
-        return super.onPrepareOptionsMenu(menu)
-    }
-
-    override fun onCompatOptionsItemSelected(item: MenuItem): Boolean {
-        when (item.itemId) {
-            R.id.menu_open_in_browser -> openUrl(viewModel.baseUrl)
-            R.id.menu_copy_url -> sendToClip(viewModel.baseUrl)
-            R.id.menu_ok -> {
-                if (viewModel.sourceVerificationEnable) {
-                    viewModel.saveVerificationResult(binding.webView) {
-                        finish()
-                    }
-                } else {
-                    finish()
-                }
-            }
-
-            R.id.menu_full_screen -> toggleFullScreen()
-            R.id.menu_disable_source -> {
-                viewModel.disableSource {
-                    finish()
-                }
-            }
-
-            R.id.menu_delete_source -> {
-                alert(R.string.draw) {
-                    setMessage(getString(R.string.sure_del) + "\n" + viewModel.sourceName)
-                    noButton()
-                    yesButton {
-                        viewModel.deleteSource {
-                            finish()
-                        }
-                    }
-                }
-            }
-        }
-        return super.onCompatOptionsItemSelected(item)
-    }
-
-    //实现starBrowser调起页面全屏
-    private fun toggleFullScreen() {
-        isFullScreen = !isFullScreen
-
-        toggleSystemBar(!isFullScreen)
-
-        if (isFullScreen) {
-            supportActionBar?.hide()
-        } else {
-            supportActionBar?.show()
-        }
-    }
-
-    @SuppressLint("SetJavaScriptEnabled")
-    private fun initWebView(url: String, headerMap: HashMap<String, String>) {
-        //binding.progressBar.fontColor = accentColor
-        binding.webView.webChromeClient = CustomWebChromeClient()
-        binding.webView.webViewClient = CustomWebViewClient()
-        binding.webView.settings.apply {
-            setDarkeningAllowed(AppConfig.isNightTheme)
-            mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
-            domStorageEnabled = true
-            allowContentAccess = true
-            useWideViewPort = true
-            loadWithOverviewMode = true
-            javaScriptEnabled = true
-            builtInZoomControls = true
-            displayZoomControls = false
-            headerMap[AppConst.UA_NAME]?.let {
-                userAgentString = it
-            }
-        }
-        AppCookieManager.applyToWebView(url)
-        binding.webView.setOnLongClickListener {
-            val hitTestResult = binding.webView.hitTestResult
-            if (hitTestResult.type == WebView.HitTestResult.IMAGE_TYPE ||
-                hitTestResult.type == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE
-            ) {
-                hitTestResult.extra?.let { webPic ->
-                    selector(
-                        arrayListOf(
-                            SelectItem(getString(R.string.action_save), "save"),
-                        )
-                    ) { _, charSequence, _ ->
-                        when (charSequence.value) {
-                            "save" -> saveImage(webPic)
-                        }
-                    }
-                    return@setOnLongClickListener false
-                }
-            }
-            return@setOnLongClickListener false
-        }
-        binding.webView.setDownloadListener { downloadUrl, _, contentDisposition, _, _ ->
-            var fileName = URLUtil.guessFileName(downloadUrl, contentDisposition, null)
-            fileName = URLDecoder.decode(fileName, "UTF-8")
-            binding.llView.longSnackbar(fileName, getString(R.string.action_download)) {
-                Download.start(this, downloadUrl, fileName)
-            }
-        }
-    }
-
-    private fun saveImage(webPic: String) {
-        this.webPic = webPic
-        viewModel.saveImage(webPic)
-    }
-
-    override fun finish() {
-        SourceVerificationHelp.checkResult(viewModel.sourceOrigin)
-        super.finish()
-    }
-
-    override fun onDestroy() {
-        super.onDestroy()
-        binding.webView.destroy()
-    }
-
-    inner class CustomWebChromeClient : WebChromeClient() {
-
-        override fun onProgressChanged(view: WebView?, newProgress: Int) {
-            super.onProgressChanged(view, newProgress)
-            binding.progressBar.setProgress(newProgress)
-            binding.progressBar.gone(newProgress == 100)
-        }
-
-        override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
-            requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
-            binding.llView.invisible()
-            binding.customWebView.addView(view)
-            customWebViewCallback = callback
-            keepScreenOn(true)
-            toggleSystemBar(false)
-        }
-
-        override fun onHideCustomView() {
-            binding.customWebView.removeAllViews()
-            binding.llView.visible()
-            requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
-            keepScreenOn(false)
-            toggleSystemBar(true)
-        }
-    }
-
-    inner class CustomWebViewClient : WebViewClient() {
-        override fun shouldOverrideUrlLoading(
-            view: WebView?,
-            request: WebResourceRequest?
-        ): Boolean {
-            request?.let {
-                return shouldOverrideUrlLoading(it.url)
-            }
-            return true
-        }
-
-        @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION", "KotlinRedundantDiagnosticSuppress")
-        override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
-            url?.let {
-                return shouldOverrideUrlLoading(Uri.parse(it))
-            }
-            return true
-        }
-
-        override fun onPageFinished(view: WebView?, url: String?) {
-            super.onPageFinished(view, url)
-            val cookieManager = CookieManager.getInstance()
-            url?.let {
-                CookieStore.setCookie(it, cookieManager.getCookie(it))
-            }
-            view?.title?.let { title ->
-                if (title != url && title != view.url && title.isNotBlank()) {
-                    binding.titleBar.title = title
-                } else {
-                    binding.titleBar.title = intent.getStringExtra("title")
-                }
-                view.evaluateJavascript("!!window._cf_chl_opt") {
-                    if (it == "true") {
-                        isCloudflareChallenge = true
-                    } else if (isCloudflareChallenge && viewModel.sourceVerificationEnable) {
-                        viewModel.saveVerificationResult(binding.webView) {
-                            finish()
-                        }
-                    }
-                }
-            }
-        }
-
-        private fun shouldOverrideUrlLoading(url: Uri): Boolean {
-            when (url.scheme) {
-                "http", "https" -> {
-                    return false
-                }
-
-                "legado", "yuedu" -> {
-                    startActivity<OnLineImportActivity> {
-                        data = url
-                    }
-                    return true
-                }
-
-                else -> {
-                    binding.root.longSnackbar(R.string.jump_to_another_app, R.string.confirm) {
-                        openUrl(url)
-                    }
-                    return true
-                }
-            }
-        }
-
-        @SuppressLint("WebViewClientOnReceivedSslError")
-        override fun onReceivedSslError(
-            view: WebView?,
-            handler: SslErrorHandler?,
-            error: SslError?
-        ) {
-            handler?.proceed()
-        }
-
-    }
-
-}

+ 199 - 0
app/src/main/java/io/legado/app/ui/browser/WebViewRouteScreen.kt

@@ -0,0 +1,199 @@
+package io.legado.app.ui.browser
+
+import android.annotation.SuppressLint
+import android.content.Intent
+import android.view.View
+import android.webkit.SslErrorHandler
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.OpenInBrowser
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.viewinterop.AndroidView
+import io.legado.app.R
+import io.legado.app.constant.AppConst
+import io.legado.app.help.http.CookieManager
+import io.legado.app.help.http.CookieStore
+import io.legado.app.help.source.SourceVerificationHelp
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBar
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.openUrl
+import android.webkit.CookieManager as AndroidCookieManager
+
+@SuppressLint("SetJavaScriptEnabled", "WebViewClientOnReceivedSslError")
+@Composable
+fun WebViewRouteScreen(
+    intent: Intent,
+    viewModel: WebViewModel,
+    onFinish: () -> Unit,
+) {
+    val context = LocalContext.current
+    var ready by remember { mutableStateOf(false) }
+    var progress by remember { mutableIntStateOf(0) }
+    var webView by remember { mutableStateOf<WebView?>(null) }
+    var customView by remember { mutableStateOf<View?>(null) }
+    var customViewCallback by remember { mutableStateOf<WebChromeClient.CustomViewCallback?>(null) }
+    var cloudflareChallenge by remember { mutableStateOf(false) }
+
+    LaunchedEffect(intent) {
+        viewModel.initData(intent) { ready = true }
+    }
+
+    fun finishScreen() {
+        if (viewModel.sourceVerificationEnable) {
+            SourceVerificationHelp.checkResult(viewModel.sourceOrigin)
+        }
+        onFinish()
+    }
+
+    fun finishWithVerification() {
+        val currentWebView = webView ?: return finishScreen()
+        viewModel.saveVerificationResult(currentWebView, ::finishScreen)
+    }
+
+    BackHandler {
+        when {
+            customView != null -> customViewCallback?.onCustomViewHidden()
+            webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size
+                ?: 0) > 1 -> webView?.goBack()
+
+            else -> finishScreen()
+        }
+    }
+
+    AppScaffold(
+        topBar = {
+            GlassTopAppBar(
+                title = viewModel.sourceName.ifBlank {
+                    intent.getStringExtra("title") ?: context.getString(R.string.loading)
+                },
+                navigationIcon = { TopBarNavigationButton(onClick = ::finishScreen) },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { webView?.reload() },
+                        imageVector = Icons.Default.Refresh,
+                        contentDescription = context.getString(R.string.refresh),
+                    )
+                    TopBarActionButton(
+                        onClick = { webView?.url?.let(context::openUrl) },
+                        imageVector = Icons.Default.OpenInBrowser,
+                        contentDescription = context.getString(R.string.open_in_browser),
+                    )
+                    TopBarActionButton(
+                        onClick = ::finishWithVerification,
+                        imageVector = Icons.Default.Check,
+                        contentDescription = context.getString(R.string.ok),
+                    )
+                },
+            )
+        },
+    ) { paddingValues ->
+        Box(Modifier
+            .fillMaxSize()
+            .padding(paddingValues)) {
+            if (ready) {
+                AndroidView(
+                    modifier = Modifier.fillMaxSize(),
+                    factory = { viewContext ->
+                        WebView(viewContext).apply {
+                            settings.apply {
+                                mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+                                domStorageEnabled = true
+                                allowContentAccess = true
+                                useWideViewPort = true
+                                loadWithOverviewMode = true
+                                builtInZoomControls = true
+                                displayZoomControls = false
+                                javaScriptEnabled = true
+                                viewModel.headerMap[AppConst.UA_NAME]?.let { userAgentString = it }
+                            }
+                            webViewClient = object : WebViewClient() {
+                                override fun shouldOverrideUrlLoading(
+                                    view: WebView,
+                                    request: WebResourceRequest
+                                ): Boolean =
+                                    request.url.scheme !in setOf("http", "https")
+
+                                override fun onPageFinished(view: WebView, url: String) {
+                                    AndroidCookieManager.getInstance().getCookie(url)
+                                        ?.let { CookieStore.setCookie(viewModel.sourceOrigin, it) }
+                                    if (viewModel.sourceVerificationEnable) view.evaluateJavascript(
+                                        "!!window._cf_chl_opt"
+                                    ) {
+                                        if (it == "true") {
+                                            cloudflareChallenge = true
+                                        } else if (cloudflareChallenge) {
+                                            finishWithVerification()
+                                        }
+                                    }
+                                }
+
+                                override fun onReceivedSslError(
+                                    view: WebView?,
+                                    handler: SslErrorHandler?,
+                                    error: android.net.http.SslError?
+                                ) {
+                                    handler?.proceed()
+                                }
+                            }
+                            webChromeClient = object : WebChromeClient() {
+                                override fun onProgressChanged(view: WebView?, newProgress: Int) {
+                                    progress = newProgress
+                                }
+
+                                override fun onShowCustomView(
+                                    view: View?,
+                                    callback: CustomViewCallback?
+                                ) {
+                                    customView = view; customViewCallback = callback
+                                }
+
+                                override fun onHideCustomView() {
+                                    customView = null; customViewCallback = null
+                                }
+                            }
+                            CookieManager.applyToWebView(viewModel.baseUrl)
+                            val html = viewModel.html
+                            if (html.isNullOrEmpty()) loadUrl(
+                                viewModel.baseUrl,
+                                viewModel.headerMap
+                            )
+                            else loadDataWithBaseURL(
+                                viewModel.baseUrl,
+                                html,
+                                "text/html",
+                                "utf-8",
+                                viewModel.baseUrl
+                            )
+                            webView = this
+                        }
+                    },
+                )
+            }
+            if (progress in 0..99) AppLinearProgressIndicator(Modifier.fillMaxWidth())
+        }
+    }
+    DisposableEffect(Unit) { onDispose { webView?.destroy() } }
+}

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt

@@ -37,6 +37,7 @@ object BookshelfConfig {
     val bookshelfFolderLayoutListLandscape get() = settings.bookshelfFolderLayoutListLandscape
     val bookshelfFolderLayoutListLandscape get() = settings.bookshelfFolderLayoutListLandscape
     val bookshelfGridLayout get() = settings.bookshelfGridLayout
     val bookshelfGridLayout get() = settings.bookshelfGridLayout
     val bookshelfLayoutCompact get() = settings.bookshelfLayoutCompact
     val bookshelfLayoutCompact get() = settings.bookshelfLayoutCompact
+    val bookshelfListCoverCenter get() = settings.bookshelfListCoverCenter
     val bookshelfListIntroBelowContent get() = settings.bookshelfListIntroBelowContent
     val bookshelfListIntroBelowContent get() = settings.bookshelfListIntroBelowContent
     val bookshelfShowDivider get() = settings.bookshelfShowDivider
     val bookshelfShowDivider get() = settings.bookshelfShowDivider
     val bookshelfTitleSmallFont get() = settings.bookshelfTitleSmallFont
     val bookshelfTitleSmallFont get() = settings.bookshelfTitleSmallFont

+ 2 - 2
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumManageScreen.kt

@@ -44,7 +44,7 @@ import androidx.compose.ui.platform.LocalContext
 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding
@@ -52,8 +52,8 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.AppIconButton
 import io.legado.app.ui.widget.components.button.AppIconButton
-import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon

+ 1 - 2
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverAlbumSelectSheet.kt

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
 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.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
@@ -22,7 +21,7 @@ import androidx.compose.ui.draw.clip
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 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 coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton

+ 149 - 52
app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt

@@ -1,7 +1,9 @@
 package io.legado.app.ui.config.themeConfig
 package io.legado.app.ui.config.themeConfig
 
 
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.aspectRatio
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
@@ -17,86 +19,181 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 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 coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+import io.legado.app.ui.widget.components.text.AppText
 
 
+/** 背景图片对应的额外选项:应用背景图为模糊,大容器/项目背景图为透明度。 */
+sealed interface BackgroundImageExtraOption {
+    data class Blur(
+        val lightTitle: String,
+        val darkTitle: String,
+        val lightValue: Int,
+        val darkValue: Int,
+        val onLightChange: (Int) -> Unit,
+        val onDarkChange: (Int) -> Unit,
+    ) : BackgroundImageExtraOption
+
+    data class Opacity(
+        val title: String,
+        val value: Int,
+        val onValueChange: (Int) -> Unit,
+    ) : BackgroundImageExtraOption
+}
+
+/**
+ * 通用亮/暗背景图管理面板:左右两个图片容器(日间/夜间,空时显示添加卡片,
+ * 有图时显示图片与关闭按钮),下方是对应的额外选项(背景虚化或背景图透明度)。
+ */
 @OptIn(ExperimentalMaterial3Api::class)
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 @Composable
 fun BackgroundImageManageSheet(
 fun BackgroundImageManageSheet(
-    isDarkTheme: Boolean?,
-    currentPath: String?,
+    show: Boolean,
     onDismissRequest: () -> Unit,
     onDismissRequest: () -> Unit,
-    onSelectImage: (Boolean) -> Unit,
-    onRemoveImage: (Boolean) -> Unit,
+    title: String,
+    lightPath: String?,
+    darkPath: String?,
+    extraOption: BackgroundImageExtraOption? = null,
+    onSelectLight: () -> Unit,
+    onSelectDark: () -> Unit,
+    onRemoveLight: () -> Unit,
+    onRemoveDark: () -> Unit,
 ) {
 ) {
     AppModalBottomSheet(
     AppModalBottomSheet(
-        data = isDarkTheme,
+        show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
-        title = stringResource(R.string.background_image),
-    ) { isDark ->
+        title = title,
+    ) {
         Column(
         Column(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxWidth()
                 .fillMaxWidth()
-                .padding(bottom = 24.dp)
+                .padding(bottom = 24.dp),
+            verticalArrangement = Arrangement.spacedBy(2.dp)
         ) {
         ) {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(12.dp),
+                modifier = Modifier.fillMaxWidth()
+            ) {
+                BackgroundImageTile(
+                    label = stringResource(R.string.day),
+                    path = lightPath,
+                    modifier = Modifier.weight(1f),
+                    onSelect = onSelectLight,
+                    onRemove = onRemoveLight,
+                )
+                BackgroundImageTile(
+                    label = stringResource(R.string.night),
+                    path = darkPath,
+                    modifier = Modifier.weight(1f),
+                    onSelect = onSelectDark,
+                    onRemove = onRemoveDark,
+                )
+            }
 
 
-            if (currentPath.isNullOrBlank()) {
-                NormalCard(
-                    onClick = { onSelectImage(isDark) },
-                    cornerRadius = 12.dp,
-                    containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .aspectRatio(16f / 9f)
-                        .padding(16.dp)
-                ) {
-                    Box(
-                        modifier = Modifier.fillMaxSize(),
-                        contentAlignment = Alignment.Center
-                    ) {
-                        AppIcon(
-                            imageVector = Icons.Default.Add,
-                            contentDescription = stringResource(R.string.add),
-                            modifier = Modifier.size(48.dp),
-                            tint = LegadoTheme.colorScheme.primary
+            when (val option = extraOption) {
+                is BackgroundImageExtraOption.Blur -> {
+                    if (!lightPath.isNullOrBlank()) {
+                        SliderSettingItem(
+                            title = option.lightTitle,
+                            value = option.lightValue.toFloat(),
+                            defaultValue = 0f,
+                            valueRange = 0f..100f,
+                            onValueChange = { option.onLightChange(it.toInt()) },
                         )
                         )
                     }
                     }
+                    if (!darkPath.isNullOrBlank()) {
+                        SliderSettingItem(
+                            title = option.darkTitle,
+                            value = option.darkValue.toFloat(),
+                            defaultValue = 0f,
+                            valueRange = 0f..100f,
+                            onValueChange = { option.onDarkChange(it.toInt()) },
+                        )
+                    }
+                }
+
+                is BackgroundImageExtraOption.Opacity -> {
+                    SliderSettingItem(
+                        title = option.title,
+                        description = "${option.value}%",
+                        value = option.value.toFloat(),
+                        defaultValue = 100f,
+                        valueRange = 0f..100f,
+                        onValueChange = { option.onValueChange(it.toInt()) },
+                    )
                 }
                 }
-            } else {
+
+                null -> Unit
+            }
+        }
+    }
+}
+
+@Composable
+private fun BackgroundImageTile(
+    label: String,
+    path: String?,
+    modifier: Modifier,
+    onSelect: () -> Unit,
+    onRemove: () -> Unit,
+) {
+    Column(modifier = modifier) {
+        if (path.isNullOrBlank()) {
+            NormalCard(
+                onClick = onSelect,
+                cornerRadius = 12.dp,
+                containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .aspectRatio(1f)
+            ) {
                 Box(
                 Box(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .padding(16.dp)
+                    modifier = Modifier.fillMaxSize(),
+                    contentAlignment = Alignment.Center
                 ) {
                 ) {
-                    NormalCard(
-                        cornerRadius = 12.dp,
-                    ) {
-                        AsyncImage(
-                            model = currentPath,
-                            contentDescription = null,
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .aspectRatio(9f / 16f),
-                            contentScale = ContentScale.Crop
-                        )
-                    }
-                    SmallTonalButton(
-                        onClick = { onRemoveImage(isDark) },
+                    AppIcon(
+                        imageVector = Icons.Default.Add,
+                        contentDescription = stringResource(R.string.add),
+                        modifier = Modifier.size(48.dp),
+                        tint = LegadoTheme.colorScheme.primary
+                    )
+                }
+            }
+        } else {
+            Box(modifier = Modifier.fillMaxWidth()) {
+                NormalCard(
+                    cornerRadius = 12.dp,
+                ) {
+                    AsyncImage(
+                        model = path,
+                        contentDescription = null,
                         modifier = Modifier
                         modifier = Modifier
-                            .align(Alignment.TopEnd)
-                            .padding(8.dp)
-                            .size(32.dp),
-                        icon = Icons.Default.Close,
-                        contentDescription = stringResource(R.string.close)
+                            .fillMaxWidth()
+                            .aspectRatio(1f),
+                        contentScale = ContentScale.Crop
                     )
                     )
                 }
                 }
+                SmallTonalButton(
+                    onClick = onRemove,
+                    modifier = Modifier
+                        .align(Alignment.TopEnd)
+                        .padding(8.dp)
+                        .size(32.dp),
+                    icon = Icons.Default.Close,
+                    contentDescription = stringResource(R.string.close)
+                )
             }
             }
         }
         }
+        AppText(
+            text = label,
+            style = LegadoTheme.typography.labelSmall,
+            modifier = Modifier.padding(start = 4.dp, top = 8.dp)
+        )
     }
     }
-
 }
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt

@@ -20,7 +20,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 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 coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.domain.model.settings.AppShellSettings
 import io.legado.app.domain.model.settings.AppShellSettings
 import io.legado.app.ui.main.MainDestination
 import io.legado.app.ui.main.MainDestination

+ 4 - 2
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigContract.kt

@@ -16,8 +16,7 @@ data class ThemeConfigUiState(
 )
 )
 
 
 sealed interface ThemeConfigSheet {
 sealed interface ThemeConfigSheet {
-    data class Background(val dark: Boolean) : ThemeConfigSheet
-    data class ContainerBackground(val target: ContainerBackgroundTarget, val dark: Boolean) : ThemeConfigSheet
+    data class BackgroundImage(val target: BackgroundImageTarget) : ThemeConfigSheet
     data object MainNavigation : ThemeConfigSheet
     data object MainNavigation : ThemeConfigSheet
     data object LauncherIcon : ThemeConfigSheet
     data object LauncherIcon : ThemeConfigSheet
     data object DividerColor : ThemeConfigSheet
     data object DividerColor : ThemeConfigSheet
@@ -25,6 +24,9 @@ sealed interface ThemeConfigSheet {
     data object Font : ThemeConfigSheet
     data object Font : ThemeConfigSheet
 }
 }
 
 
+/** 背景图片类型:应用的背景图片 / 大容器背景图片 / 项目背景图片 */
+enum class BackgroundImageTarget { App, LargeContainer, Item }
+
 enum class ContainerBackgroundTarget { LargeContainer, Item }
 enum class ContainerBackgroundTarget { LargeContainer, Item }
 
 
 sealed interface ThemeConfigDialog {
 sealed interface ThemeConfigDialog {

+ 145 - 135
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -309,6 +309,19 @@ fun ThemeConfigScreen(
                         description = stringResource(R.string.theme_pack_s),
                         description = stringResource(R.string.theme_pack_s),
                         onClick = onNavigateToThemeManage
                         onClick = onNavigateToThemeManage
                     )
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.background_image),
+                        description = "日间/夜间背景图与背景虚化",
+                        onClick = {
+                            onIntent(
+                                ThemeConfigIntent.ShowSheet(
+                                    ThemeConfigSheet.BackgroundImage(
+                                        BackgroundImageTarget.App
+                                    )
+                                )
+                            )
+                        }
+                    )
                 }
                 }
 
 
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
                 SplicedColumnGroup(title = stringResource(R.string.main_activity)) {
@@ -608,56 +621,6 @@ fun ThemeConfigScreen(
                     }
                     }
                 }
                 }
 
 
-                SplicedColumnGroup(title = stringResource(R.string.background_image)) {
-                    val hasLightBg = !theme.backgroundImageLight.isNullOrBlank()
-                    ClickableSettingItem(
-                        title = stringResource(R.string.day),
-                        description = if (hasLightBg) stringResource(R.string.click_to_delete) else stringResource(
-                            R.string.select_image
-                        ),
-                        onClick = {
-                            onIntent(
-                                ThemeConfigIntent.ShowSheet(ThemeConfigSheet.Background(false))
-                            )
-                        }
-                    )
-
-                    if (hasLightBg) {
-                        SliderSettingItem(
-                            title = stringResource(R.string.background_image_blurring),
-                            value = theme.backgroundImageBlurring.toFloat(),
-                            defaultValue = 0f,
-                            valueRange = 0f..100f,
-                            onValueChange = { value ->
-                                updateTheme { it.copy(backgroundImageBlurring = value.toInt()) }
-                            }
-                        )
-                    }
-                    val hasDarkBg = !theme.backgroundImageDark.isNullOrBlank()
-                    ClickableSettingItem(
-                        title = stringResource(R.string.night),
-                        description = if (hasDarkBg) stringResource(R.string.click_to_delete) else stringResource(
-                            R.string.select_image
-                        ),
-                        onClick = {
-                            onIntent(
-                                ThemeConfigIntent.ShowSheet(ThemeConfigSheet.Background(true))
-                            )
-                        }
-                    )
-
-                    if (hasDarkBg) {
-                        SliderSettingItem(
-                            title = stringResource(R.string.background_image_blurring),
-                            value = theme.backgroundImageDarkBlurring.toFloat(),
-                            defaultValue = 0f,
-                            valueRange = 0f..100f,
-                            onValueChange = { value ->
-                                updateTheme { it.copy(backgroundImageDarkBlurring = value.toInt()) }
-                            }
-                        )
-                    }
-                }
             }
             }
 
 
             // Container settings
             // Container settings
@@ -672,66 +635,46 @@ fun ThemeConfigScreen(
                         }
                         }
                     )
                     )
                     AnimatedVisibility(visible = theme.enableContainerBackgroundImage) {
                     AnimatedVisibility(visible = theme.enableContainerBackgroundImage) {
-                        Column {
-                            ContainerBackgroundSettingItem(
-                                title = "大容器日间背景图片",
-                                path = theme.largeContainerBackgroundImageLight,
-                                onClick = {
-                                    onIntent(ThemeConfigIntent.RequestContainerBackgroundImage(ContainerBackgroundTarget.LargeContainer, false))
-                                },
-                                onRemove = {
-                                    onIntent(ThemeConfigIntent.RemoveContainerBackground(ContainerBackgroundTarget.LargeContainer, false))
+                        Column(
+                            verticalArrangement = Arrangement.spacedBy(2.dp)
+                        ) {
+                            ClickableSettingItem(
+                                title = "大容器背景图片",
+                                description = if (theme.largeContainerBackgroundImageLight.isNullOrBlank() &&
+                                    theme.largeContainerBackgroundImageDark.isNullOrBlank()
+                                ) {
+                                    stringResource(R.string.select_image)
+                                } else {
+                                    "已选择"
                                 },
                                 },
-                            )
-                            ContainerBackgroundSettingItem(
-                                title = "大容器夜间背景图片",
-                                path = theme.largeContainerBackgroundImageDark,
                                 onClick = {
                                 onClick = {
-                                    onIntent(ThemeConfigIntent.RequestContainerBackgroundImage(ContainerBackgroundTarget.LargeContainer, true))
-                                },
-                                onRemove = {
-                                    onIntent(ThemeConfigIntent.RemoveContainerBackground(ContainerBackgroundTarget.LargeContainer, true))
-                                },
-                            )
-                            SliderSettingItem(
-                                title = "大容器背景图透明度",
-                                description = "${theme.appColumnBackgroundOpacity}%",
-                                value = theme.appColumnBackgroundOpacity.toFloat(),
-                                defaultValue = 100f,
-                                valueRange = 0f..100f,
-                                onValueChange = { value ->
-                                    updateTheme { it.copy(appColumnBackgroundOpacity = value.toInt()) }
-                                }
-                            )
-                            SliderSettingItem(
-                                title = "项目背景图透明度",
-                                description = "${theme.glassCardBackgroundOpacity}%",
-                                value = theme.glassCardBackgroundOpacity.toFloat(),
-                                defaultValue = 100f,
-                                valueRange = 0f..100f,
-                                onValueChange = { value ->
-                                    updateTheme { it.copy(glassCardBackgroundOpacity = value.toInt()) }
+                                    onIntent(
+                                        ThemeConfigIntent.ShowSheet(
+                                            ThemeConfigSheet.BackgroundImage(
+                                                BackgroundImageTarget.LargeContainer
+                                            )
+                                        )
+                                    )
                                 }
                                 }
                             )
                             )
-                            ContainerBackgroundSettingItem(
-                                title = "项目日间背景图片",
-                                path = theme.itemBackgroundImageLight,
-                                onClick = {
-                                    onIntent(ThemeConfigIntent.RequestContainerBackgroundImage(ContainerBackgroundTarget.Item, false))
-                                },
-                                onRemove = {
-                                    onIntent(ThemeConfigIntent.RemoveContainerBackground(ContainerBackgroundTarget.Item, false))
+                            ClickableSettingItem(
+                                title = "项目背景图片",
+                                description = if (theme.itemBackgroundImageLight.isNullOrBlank() &&
+                                    theme.itemBackgroundImageDark.isNullOrBlank()
+                                ) {
+                                    stringResource(R.string.select_image)
+                                } else {
+                                    "已选择"
                                 },
                                 },
-                            )
-                            ContainerBackgroundSettingItem(
-                                title = "项目夜间背景图片",
-                                path = theme.itemBackgroundImageDark,
                                 onClick = {
                                 onClick = {
-                                    onIntent(ThemeConfigIntent.RequestContainerBackgroundImage(ContainerBackgroundTarget.Item, true))
-                                },
-                                onRemove = {
-                                    onIntent(ThemeConfigIntent.RemoveContainerBackground(ContainerBackgroundTarget.Item, true))
-                                },
+                                    onIntent(
+                                        ThemeConfigIntent.ShowSheet(
+                                            ThemeConfigSheet.BackgroundImage(
+                                                BackgroundImageTarget.Item
+                                            )
+                                        )
+                                    )
+                                }
                             )
                             )
                         }
                         }
                     }
                     }
@@ -932,14 +875,106 @@ fun ThemeConfigScreen(
     }
     }
 
 
 
 
+    val backgroundImageSheet = state.activeSheet as? ThemeConfigSheet.BackgroundImage
+    val backgroundImageTarget = backgroundImageSheet?.target
+    fun requestImage(dark: Boolean) {
+        when (backgroundImageTarget) {
+            BackgroundImageTarget.App ->
+                onIntent(ThemeConfigIntent.RequestBackgroundImage(dark))
+
+            BackgroundImageTarget.LargeContainer ->
+                onIntent(
+                    ThemeConfigIntent.RequestContainerBackgroundImage(
+                        ContainerBackgroundTarget.LargeContainer,
+                        dark
+                    )
+                )
+
+            BackgroundImageTarget.Item ->
+                onIntent(
+                    ThemeConfigIntent.RequestContainerBackgroundImage(
+                        ContainerBackgroundTarget.Item,
+                        dark
+                    )
+                )
+
+            null -> Unit
+        }
+    }
+
+    fun removeImage(dark: Boolean) {
+        when (backgroundImageTarget) {
+            BackgroundImageTarget.App ->
+                onIntent(ThemeConfigIntent.RemoveBackground(dark))
+
+            BackgroundImageTarget.LargeContainer ->
+                onIntent(
+                    ThemeConfigIntent.RemoveContainerBackground(
+                        ContainerBackgroundTarget.LargeContainer,
+                        dark
+                    )
+                )
+
+            BackgroundImageTarget.Item ->
+                onIntent(
+                    ThemeConfigIntent.RemoveContainerBackground(
+                        ContainerBackgroundTarget.Item,
+                        dark
+                    )
+                )
+
+            null -> Unit
+        }
+    }
     BackgroundImageManageSheet(
     BackgroundImageManageSheet(
-        isDarkTheme = (state.activeSheet as? ThemeConfigSheet.Background)?.dark,
-        currentPath = (state.activeSheet as? ThemeConfigSheet.Background)?.let {
-            if (it.dark) theme.backgroundImageDark else theme.backgroundImageLight
-        },
+        show = backgroundImageSheet != null,
         onDismissRequest = { onIntent(ThemeConfigIntent.DismissSheet) },
         onDismissRequest = { onIntent(ThemeConfigIntent.DismissSheet) },
-        onSelectImage = { onIntent(ThemeConfigIntent.RequestBackgroundImage(it)) },
-        onRemoveImage = { onIntent(ThemeConfigIntent.RemoveBackground(it)) },
+        title = when (backgroundImageTarget) {
+            BackgroundImageTarget.App -> stringResource(R.string.background_image)
+            BackgroundImageTarget.LargeContainer -> "大容器背景图片"
+            BackgroundImageTarget.Item -> "项目背景图片"
+            null -> ""
+        },
+        lightPath = when (backgroundImageTarget) {
+            BackgroundImageTarget.App -> theme.backgroundImageLight
+            BackgroundImageTarget.LargeContainer -> theme.largeContainerBackgroundImageLight
+            BackgroundImageTarget.Item -> theme.itemBackgroundImageLight
+            null -> null
+        },
+        darkPath = when (backgroundImageTarget) {
+            BackgroundImageTarget.App -> theme.backgroundImageDark
+            BackgroundImageTarget.LargeContainer -> theme.largeContainerBackgroundImageDark
+            BackgroundImageTarget.Item -> theme.itemBackgroundImageDark
+            null -> null
+        },
+        extraOption = when (backgroundImageTarget) {
+            BackgroundImageTarget.App -> BackgroundImageExtraOption.Blur(
+                lightTitle = "日间背景图片虚化",
+                darkTitle = "夜间背景图片虚化",
+                lightValue = theme.backgroundImageBlurring,
+                darkValue = theme.backgroundImageDarkBlurring,
+                onLightChange = { value -> updateTheme { it.copy(backgroundImageBlurring = value) } },
+                onDarkChange = { value -> updateTheme { it.copy(backgroundImageDarkBlurring = value) } },
+            )
+
+            BackgroundImageTarget.LargeContainer -> BackgroundImageExtraOption.Opacity(
+                title = "大容器背景图透明度",
+                value = theme.appColumnBackgroundOpacity,
+                onValueChange = { value -> updateTheme { it.copy(appColumnBackgroundOpacity = value) } },
+            )
+
+            BackgroundImageTarget.Item -> BackgroundImageExtraOption.Opacity(
+                title = "项目背景图透明度",
+                value = theme.glassCardBackgroundOpacity,
+                onValueChange = { value -> updateTheme { it.copy(glassCardBackgroundOpacity = value) } },
+            )
+
+            null -> null
+        },
+        onSelectLight = { requestImage(false) },
+        onSelectDark = { requestImage(true) },
+        onRemoveLight = { removeImage(false) },
+        onRemoveDark = { removeImage(true) },
     )
     )
 
 
     MainNavigationSettingsSheet(
     MainNavigationSettingsSheet(
@@ -1022,31 +1057,6 @@ fun ThemeConfigScreen(
 
 
 }
 }
 
 
-@Composable
-private fun ContainerBackgroundSettingItem(
-    title: String,
-    path: String?,
-    onClick: () -> Unit,
-    onRemove: () -> Unit,
-) {
-    ClickableSettingItem(
-        title = title,
-        description = if (path.isNullOrBlank()) "选择图片" else "已选择",
-        onClick = onClick,
-        trailingContent = if (path.isNullOrBlank()) {
-            null
-        } else {
-            {
-                SmallPlainButton(
-                    onClick = onRemove,
-                    icon = Icons.Default.Delete,
-                    contentDescription = stringResource(R.string.clear),
-                )
-            }
-        },
-    )
-}
-
 @Composable
 @Composable
 private fun BaseCardBorderColorSettingItem(
 private fun BaseCardBorderColorSettingItem(
     title: String,
     title: String,

+ 201 - 0
app/src/main/java/io/legado/app/ui/dict/DictHtmlContent.kt

@@ -0,0 +1,201 @@
+package io.legado.app.ui.dict
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.foundation.text.InlineTextContent
+import androidx.compose.foundation.text.appendInlineContent
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.Placeholder
+import androidx.compose.ui.text.PlaceholderVerticalAlign
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.BaselineShift
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.core.graphics.toColorInt
+import coil3.compose.AsyncImagePainter
+import coil3.compose.rememberAsyncImagePainter
+import io.legado.app.ui.theme.LegadoTheme
+
+@Composable
+internal fun DictHtmlContent(
+    htmlContent: String,
+    modifier: Modifier = Modifier,
+) {
+    val document = remember(htmlContent) { DictHtmlParser.parse(htmlContent) }
+    val baseStyle =
+        LegadoTheme.typography.bodyMedium.merge(color = LegadoTheme.colorScheme.onSurface)
+    val linkColor = LegadoTheme.colorScheme.primary
+
+    BoxWithConstraints(modifier = modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
+        val availableWidthPx = constraints.maxWidth
+        Column {
+            document.paragraphs.forEachIndexed { paragraphIndex, paragraph ->
+                DictHtmlParagraphContent(
+                    paragraph = paragraph,
+                    paragraphIndex = paragraphIndex,
+                    maxWidthPx = availableWidthPx,
+                    baseStyle = baseStyle,
+                    linkColor = linkColor,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun DictHtmlParagraphContent(
+    paragraph: DictHtmlParagraph,
+    paragraphIndex: Int,
+    maxWidthPx: Int,
+    baseStyle: TextStyle,
+    linkColor: Color,
+) {
+    val density = LocalDensity.current
+    val inlineContent = mutableMapOf<String, InlineTextContent>()
+    var imageIndex = 0
+
+    paragraph.content.forEach { inline ->
+        if (inline !is DictHtmlInline.Image) return@forEach
+        val id = "dict-image-$paragraphIndex-${imageIndex++}"
+        val painter = rememberAsyncImagePainter(model = inline.source)
+        val image = (painter.state.value as? AsyncImagePainter.State.Success)?.result?.image
+        val drawableWidth = image?.width?.coerceAtLeast(1) ?: 1
+        val drawableHeight = image?.height?.coerceAtLeast(1) ?: 1
+        val scale = if (maxWidthPx in 1..<drawableWidth) {
+            maxWidthPx.toFloat() / drawableWidth
+        } else {
+            1f
+        }
+        val width = with(density) { (drawableWidth * scale).toDp().toSp() }
+        val height = with(density) { (drawableHeight * scale).toDp().toSp() }
+        inlineContent[id] = InlineTextContent(
+            placeholder = Placeholder(
+                width = width.value.coerceAtLeast(1f).sp,
+                height = height.value.coerceAtLeast(1f).sp,
+                placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
+            )
+        ) {
+            Image(
+                painter = painter,
+                contentDescription = inline.description,
+                modifier = Modifier.fillMaxSize(),
+                contentScale = ContentScale.Fit,
+            )
+        }
+    }
+
+    val annotatedText = buildAnnotatedString {
+        imageIndex = 0
+        paragraph.content.forEach { inline ->
+            when (inline) {
+                is DictHtmlInline.Text -> appendStyledText(inline, linkColor)
+                is DictHtmlInline.Image -> {
+                    val id = "dict-image-$paragraphIndex-${imageIndex++}"
+                    if (inline.link != null) {
+                        withLink(LinkAnnotation.Url(inline.link)) {
+                            appendInlineContent(id, inline.description ?: "image")
+                        }
+                    } else {
+                        appendInlineContent(id, inline.description ?: "image")
+                    }
+                }
+
+                DictHtmlInline.LineBreak -> append('\n')
+            }
+        }
+    }
+
+    BasicText(
+        text = annotatedText,
+        inlineContent = inlineContent,
+        style = baseStyle,
+    )
+}
+
+private fun AnnotatedString.Builder.appendStyledText(
+    text: DictHtmlInline.Text,
+    linkColor: Color,
+) {
+    val spanStyle = text.style.toSpanStyle(
+        fallbackColor = if (text.link != null) linkColor else null,
+        underlineLink = text.link != null,
+    )
+    if (text.link != null) {
+        withLink(LinkAnnotation.Url(text.link)) {
+            withStyle(spanStyle) { append(text.value) }
+        }
+    } else {
+        withStyle(spanStyle) { append(text.value) }
+    }
+}
+
+private fun DictHtmlTextStyle.toSpanStyle(
+    fallbackColor: Color?,
+    underlineLink: Boolean,
+): SpanStyle {
+    val decorations = buildList {
+        if (underline || underlineLink) add(TextDecoration.Underline)
+        if (strikeThrough) add(TextDecoration.LineThrough)
+    }
+    return SpanStyle(
+        color = parseHtmlColor(color) ?: fallbackColor ?: Color.Unspecified,
+        background = parseHtmlColor(backgroundColor) ?: Color.Unspecified,
+        fontWeight = if (bold) FontWeight.Bold else null,
+        fontStyle = if (italic) FontStyle.Italic else null,
+        fontSize = if (relativeFontSize == 1f) androidx.compose.ui.unit.TextUnit.Unspecified else relativeFontSize.em,
+        textDecoration = decorations.takeIf { it.isNotEmpty() }?.let(TextDecoration::combine),
+        baselineShift = when (baseline) {
+            DictHtmlBaseline.Normal -> null
+            DictHtmlBaseline.Subscript -> BaselineShift.Subscript
+            DictHtmlBaseline.Superscript -> BaselineShift.Superscript
+        },
+    )
+}
+
+private fun parseHtmlColor(value: String?): Color? {
+    if (value.isNullOrBlank()) return null
+    val normalized = value.trim().lowercase()
+    return runCatching {
+        when {
+            normalized.startsWith("rgb(") -> {
+                val channels = normalized.substringAfter('(').substringBefore(')').split(',')
+                    .map { it.trim().toInt().coerceIn(0, 255) }
+                if (channels.size != 3) return null
+                Color(channels[0], channels[1], channels[2])
+            }
+
+            normalized.startsWith("rgba(") -> {
+                val channels =
+                    normalized.substringAfter('(').substringBefore(')').split(',').map(String::trim)
+                if (channels.size != 4) return null
+                Color(
+                    red = channels[0].toInt().coerceIn(0, 255),
+                    green = channels[1].toInt().coerceIn(0, 255),
+                    blue = channels[2].toInt().coerceIn(0, 255),
+                    alpha = (channels[3].toFloat().coerceIn(0f, 1f) * 255).toInt(),
+                )
+            }
+
+            else -> Color(normalized.toColorInt())
+        }
+    }.getOrNull()
+}

+ 307 - 0
app/src/main/java/io/legado/app/ui/dict/DictHtmlParser.kt

@@ -0,0 +1,307 @@
+package io.legado.app.ui.dict
+
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Element
+import org.jsoup.nodes.Node
+import org.jsoup.nodes.TextNode
+
+internal data class DictHtmlDocument(
+    val paragraphs: List<DictHtmlParagraph>,
+)
+
+internal data class DictHtmlParagraph(
+    val content: List<DictHtmlInline>,
+)
+
+internal sealed interface DictHtmlInline {
+    data class Text(
+        val value: String,
+        val style: DictHtmlTextStyle,
+        val link: String? = null,
+    ) : DictHtmlInline
+
+    data class Image(
+        val source: String,
+        val description: String?,
+        val link: String? = null,
+    ) : DictHtmlInline
+
+    data object LineBreak : DictHtmlInline
+}
+
+internal data class DictHtmlTextStyle(
+    val color: String? = null,
+    val backgroundColor: String? = null,
+    val bold: Boolean = false,
+    val italic: Boolean = false,
+    val underline: Boolean = false,
+    val strikeThrough: Boolean = false,
+    val relativeFontSize: Float = 1f,
+    val baseline: DictHtmlBaseline = DictHtmlBaseline.Normal,
+)
+
+internal enum class DictHtmlBaseline { Normal, Subscript, Superscript }
+
+internal object DictHtmlParser {
+
+    private val blockTags = setOf(
+        "address", "article", "aside", "blockquote", "div", "dl", "fieldset", "figcaption",
+        "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header",
+        "hr", "main", "nav", "ol", "p", "pre", "section", "table", "ul",
+    )
+
+    fun parse(html: String): DictHtmlDocument {
+        val paragraphs = mutableListOf<DictHtmlParagraph>()
+        parseContainer(
+            nodes = Jsoup.parseBodyFragment(html).body().childNodes(),
+            inheritedStyle = DictHtmlTextStyle(),
+            inheritedLink = null,
+            paragraphs = paragraphs,
+        )
+        return DictHtmlDocument(paragraphs)
+    }
+
+    private fun parseContainer(
+        nodes: List<Node>,
+        inheritedStyle: DictHtmlTextStyle,
+        inheritedLink: String?,
+        paragraphs: MutableList<DictHtmlParagraph>,
+    ) {
+        var current = mutableListOf<DictHtmlInline>()
+
+        fun flush() {
+            trimBoundaryWhitespace(current)
+            if (current.isNotEmpty()) paragraphs += DictHtmlParagraph(current)
+            current = mutableListOf()
+        }
+
+        nodes.forEach { node ->
+            if (node is Element && node.normalName() in blockTags) {
+                flush()
+                parseBlock(node, inheritedStyle, inheritedLink, paragraphs)
+            } else {
+                parseInline(node, inheritedStyle, inheritedLink, current)
+            }
+        }
+        flush()
+    }
+
+    private fun parseBlock(
+        element: Element,
+        inheritedStyle: DictHtmlTextStyle,
+        inheritedLink: String?,
+        paragraphs: MutableList<DictHtmlParagraph>,
+    ) {
+        val style = mergeStyle(inheritedStyle, element)
+        when (element.normalName()) {
+            "div", "section", "article", "main", "header", "footer", "nav", "aside", "form",
+            "fieldset", "figure", "figcaption", "address", "blockquote" -> parseContainer(
+                element.childNodes(), style, inheritedLink, paragraphs
+            )
+
+            "ul", "ol" -> parseList(element, style, inheritedLink, paragraphs)
+            "table" -> element.select("tr").forEach { row ->
+                val content = mutableListOf<DictHtmlInline>()
+                row.select(":scope > th, :scope > td").forEachIndexed { index, cell ->
+                    if (index > 0) appendText(content, "  ", style, inheritedLink)
+                    cell.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
+                }
+                addParagraph(content, paragraphs)
+            }
+
+            "hr" -> paragraphs += DictHtmlParagraph(
+                listOf(DictHtmlInline.Text("────────", style, inheritedLink))
+            )
+
+            else -> {
+                val content = mutableListOf<DictHtmlInline>()
+                element.childNodes().forEach { parseInline(it, style, inheritedLink, content) }
+                addParagraph(content, paragraphs)
+            }
+        }
+    }
+
+    private fun parseList(
+        list: Element,
+        style: DictHtmlTextStyle,
+        link: String?,
+        paragraphs: MutableList<DictHtmlParagraph>,
+    ) {
+        val ordered = list.normalName() == "ol"
+        val start = list.attr("start").toIntOrNull() ?: 1
+        list.children().filter { it.normalName() == "li" }.forEachIndexed { index, item ->
+            val content = mutableListOf<DictHtmlInline>()
+            appendText(content, if (ordered) "${start + index}. " else "• ", style, link)
+            item.childNodes().forEach { parseInline(it, style, link, content) }
+            addParagraph(content, paragraphs)
+        }
+    }
+
+    private fun parseInline(
+        node: Node,
+        inheritedStyle: DictHtmlTextStyle,
+        inheritedLink: String?,
+        output: MutableList<DictHtmlInline>,
+    ) {
+        when (node) {
+            is TextNode -> appendText(
+                output,
+                normalizeWhitespace(node.wholeText),
+                inheritedStyle,
+                inheritedLink
+            )
+
+            is Element -> {
+                val style = mergeStyle(inheritedStyle, node)
+                val link = if (node.normalName() == "a") node.attr("href")
+                    .ifBlank { inheritedLink } else inheritedLink
+                when (node.normalName()) {
+                    "br" -> output += DictHtmlInline.LineBreak
+                    "img" -> node.attr("src").takeIf(String::isNotBlank)?.let { source ->
+                        output += DictHtmlInline.Image(
+                            source,
+                            node.attr("alt").ifBlank { null },
+                            link
+                        )
+                    }
+
+                    in blockTags -> {
+                        if (output.isNotEmpty() && output.last() !is DictHtmlInline.LineBreak) {
+                            output += DictHtmlInline.LineBreak
+                        }
+                        node.childNodes().forEach { parseInline(it, style, link, output) }
+                        if (output.isNotEmpty() && output.last() !is DictHtmlInline.LineBreak) {
+                            output += DictHtmlInline.LineBreak
+                        }
+                    }
+
+                    else -> node.childNodes().forEach { parseInline(it, style, link, output) }
+                }
+            }
+        }
+    }
+
+    private fun appendText(
+        output: MutableList<DictHtmlInline>,
+        value: String,
+        style: DictHtmlTextStyle,
+        link: String?,
+    ) {
+        if (value.isEmpty()) return
+        val previous = output.lastOrNull() as? DictHtmlInline.Text
+        if (previous != null && previous.style == style && previous.link == link) {
+            output[output.lastIndex] = previous.copy(value = previous.value + value)
+        } else {
+            output += DictHtmlInline.Text(value, style, link)
+        }
+    }
+
+    private fun addParagraph(
+        content: MutableList<DictHtmlInline>,
+        paragraphs: MutableList<DictHtmlParagraph>,
+    ) {
+        trimBoundaryWhitespace(content)
+        if (content.isNotEmpty()) paragraphs += DictHtmlParagraph(content)
+    }
+
+    private fun trimBoundaryWhitespace(content: MutableList<DictHtmlInline>) {
+        val first = content.firstOrNull() as? DictHtmlInline.Text
+        if (first != null) {
+            val trimmed = first.value.trimStart()
+            if (trimmed.isEmpty()) content.removeAt(0) else content[0] = first.copy(value = trimmed)
+        }
+        val last = content.lastOrNull() as? DictHtmlInline.Text
+        if (last != null) {
+            val trimmed = last.value.trimEnd()
+            if (trimmed.isEmpty()) content.removeAt(content.lastIndex)
+            else content[content.lastIndex] = last.copy(value = trimmed)
+        }
+        while (content.firstOrNull() is DictHtmlInline.LineBreak) content.removeAt(0)
+        while (content.lastOrNull() is DictHtmlInline.LineBreak) content.removeAt(content.lastIndex)
+    }
+
+    private fun normalizeWhitespace(value: String): String = value.replace(Regex("\\s+"), " ")
+
+    private fun mergeStyle(parent: DictHtmlTextStyle, element: Element): DictHtmlTextStyle {
+        var style = when (element.normalName()) {
+            "b", "strong" -> parent.copy(bold = true)
+            "i", "em", "cite", "dfn" -> parent.copy(italic = true)
+            "u", "ins" -> parent.copy(underline = true)
+            "s", "strike", "del" -> parent.copy(strikeThrough = true)
+            "sub" -> parent.copy(
+                relativeFontSize = parent.relativeFontSize * 0.8f,
+                baseline = DictHtmlBaseline.Subscript
+            )
+
+            "sup" -> parent.copy(
+                relativeFontSize = parent.relativeFontSize * 0.8f,
+                baseline = DictHtmlBaseline.Superscript
+            )
+
+            "small" -> parent.copy(relativeFontSize = parent.relativeFontSize * 0.8f)
+            "big" -> parent.copy(relativeFontSize = parent.relativeFontSize * 1.2f)
+            "h1" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 2f)
+            "h2" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.5f)
+            "h3" -> parent.copy(bold = true, relativeFontSize = parent.relativeFontSize * 1.17f)
+            "h4", "h5", "h6" -> parent.copy(bold = true)
+            "mark" -> parent.copy(backgroundColor = "#ffff00")
+            else -> parent
+        }
+        if (element.normalName() == "font") {
+            element.attr("color").takeIf(String::isNotBlank)?.let { style = style.copy(color = it) }
+            element.attr("size").toIntOrNull()?.let { size ->
+                val scale = when (size.coerceIn(1, 7)) {
+                    1 -> .63f; 2 -> .82f; 3 -> 1f; 4 -> 1.13f; 5 -> 1.5f; 6 -> 2f; else -> 3f
+                }
+                style = style.copy(relativeFontSize = scale)
+            }
+        }
+        element.attr("style").split(';').forEach { declaration ->
+            val (name, rawValue) = declaration.split(':', limit = 2).takeIf { it.size == 2 }
+                ?: return@forEach
+            val value = rawValue.trim().removeSuffix("!important").trim()
+            style = when (name.trim().lowercase()) {
+                "color" -> style.copy(color = value)
+                "background", "background-color" -> style.copy(backgroundColor = value)
+                "font-weight" -> style.copy(
+                    bold = value.equals("bold", true) || value.toIntOrNull()
+                        ?.let { it >= 600 } == true)
+
+                "font-style" -> style.copy(
+                    italic = value.equals(
+                        "italic",
+                        true
+                    ) || value.equals("oblique", true)
+                )
+
+                "text-decoration", "text-decoration-line" -> style.copy(
+                    underline = "underline" in value.lowercase(),
+                    strikeThrough = "line-through" in value.lowercase(),
+                )
+
+                "font-size" -> parseRelativeFontSize(value)?.let { style.copy(relativeFontSize = it) }
+                    ?: style
+
+                "vertical-align" -> style.copy(
+                    baseline = when (value.lowercase()) {
+                        "sub" -> DictHtmlBaseline.Subscript
+                        "super" -> DictHtmlBaseline.Superscript
+                        else -> DictHtmlBaseline.Normal
+                    }
+                )
+
+                else -> style
+            }
+        }
+        return style
+    }
+
+    private fun parseRelativeFontSize(value: String): Float? = when {
+        value.endsWith("em", true) -> value.dropLast(2).trim().toFloatOrNull()
+        value.endsWith("%") -> value.dropLast(1).trim().toFloatOrNull()?.div(100f)
+        value.endsWith("px", true) -> value.dropLast(2).trim().toFloatOrNull()?.div(16f)
+        value.equals("smaller", true) -> 0.8f
+        value.equals("larger", true) -> 1.2f
+        else -> null
+    }?.coerceIn(0.5f, 4f)
+}

+ 8 - 67
app/src/main/java/io/legado/app/ui/dict/DictSheet.kt

@@ -3,15 +3,15 @@ package io.legado.app.ui.dict
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.spring
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.core.tween
-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.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 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.layout.padding
 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.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -23,15 +23,10 @@ import androidx.compose.runtime.snapshotFlow
 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.clipToBounds
 import androidx.compose.ui.draw.clipToBounds
-import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.ExperimentalTextApi
-import androidx.compose.ui.text.fromHtml
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -39,7 +34,6 @@ 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.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
-import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.drop
@@ -65,7 +59,7 @@ fun DictSheet(
         show = show,
         show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
         title = word,
         title = word,
-        //内部pager管理高度,避免二次动画
+        contentPaddingEnabled = false,
         animateContentSize = false,
         animateContentSize = false,
     ) {
     ) {
         DictSheetContent(
         DictSheetContent(
@@ -175,7 +169,9 @@ private fun DictPagerContent(
                     )
                     )
                 }
                 }
             },
             },
-            modifier = Modifier.fillMaxWidth(),
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
             isScrollable = true,
             isScrollable = true,
         )
         )
 
 
@@ -262,58 +258,3 @@ private fun DictPageContent(
         }
         }
     }
     }
 }
 }
-
-@OptIn(ExperimentalTextApi::class)
-@Composable
-private fun DictHtmlContent(
-    htmlContent: String,
-    modifier: Modifier = Modifier,
-) {
-    val blocks = remember(htmlContent) { parseHtmlBlocks(htmlContent) }
-    Column(modifier = modifier.padding(horizontal = 32.dp, vertical = 16.dp)) {
-        blocks.forEach { block ->
-            when (block) {
-                is HtmlBlock.Text -> {
-                    AppText(
-                        text = remember(block.content) {
-                            AnnotatedString.fromHtml(block.content)
-                        },
-                    )
-                }
-                is HtmlBlock.Image -> {
-                    AsyncImage(
-                        model = block.url,
-                        contentDescription = null,
-                        modifier = Modifier.fillMaxWidth(),
-                        contentScale = ContentScale.FillWidth,
-                    )
-                }
-            }
-        }
-    }
-}
-
-private sealed interface HtmlBlock {
-    data class Text(val content: String) : HtmlBlock
-    data class Image(val url: String) : HtmlBlock
-}
-
-private val IMG_REGEX = Regex("""<img\s+[^>]*src\s*=\s*["']([^"']+)["'][^>]*/?>""")
-
-private fun parseHtmlBlocks(html: String): List<HtmlBlock> {
-    val blocks = mutableListOf<HtmlBlock>()
-    var lastIndex = 0
-    IMG_REGEX.findAll(html).forEach { match ->
-        if (match.range.first > lastIndex) {
-            val textPart = html.substring(lastIndex, match.range.first).trim()
-            if (textPart.isNotBlank()) blocks.add(HtmlBlock.Text(textPart))
-        }
-        blocks.add(HtmlBlock.Image(match.groupValues[1]))
-        lastIndex = match.range.last + 1
-    }
-    if (lastIndex < html.length) {
-        val remaining = html.substring(lastIndex).trim()
-        if (remaining.isNotBlank()) blocks.add(HtmlBlock.Text(remaining))
-    }
-    return blocks
-}

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

@@ -275,7 +275,7 @@ private fun SourceLoginWebSheet(
     onOpenExternalUrl: (String) -> Unit,
     onOpenExternalUrl: (String) -> Unit,
 ) {
 ) {
     AppModalBottomSheet(
     AppModalBottomSheet(
-        show = true,
+        show = state.mode == SourceLoginMode.Web && !state.loading,
         onDismissRequest = { onIntent(SourceLoginIntent.Back) },
         onDismissRequest = { onIntent(SourceLoginIntent.Back) },
         title = state.title,
         title = state.title,
         endAction = {
         endAction = {
@@ -286,6 +286,7 @@ private fun SourceLoginWebSheet(
             )
             )
         },
         },
         contentPaddingEnabled = false,
         contentPaddingEnabled = false,
+        sheetGesturesEnabled = false,
     ) {
     ) {
         SourceLoginWebView(state, onIntent, onOpenExternalUrl)
         SourceLoginWebView(state, onIntent, onOpenExternalUrl)
     }
     }
@@ -318,6 +319,7 @@ private fun SourceLoginWebView(
                         displayZoomControls = false
                         displayZoomControls = false
                         state.headers[AppConst.UA_NAME]?.let { userAgentString = it }
                         state.headers[AppConst.UA_NAME]?.let { userAgentString = it }
                     }
                     }
+                    isNestedScrollingEnabled = true
                     webViewClient = object : WebViewClient() {
                     webViewClient = object : WebViewClient() {
                         override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                         override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                             url?.let { currentIntent(SourceLoginIntent.WebPageStarted(it)) }
                             url?.let { currentIntent(SourceLoginIntent.WebPageStarted(it)) }

+ 15 - 0
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -98,6 +98,21 @@ open class MainActivity : BaseComposeActivity() {
             bookUrl: String? = null,
             bookUrl: String? = null,
         ): Intent = MainIntent.createSourceLoginIntent(context, type, sourceKey, bookUrl)
         ): Intent = MainIntent.createSourceLoginIntent(context, type, sourceKey, bookUrl)
 
 
+        fun createWebViewIntent(
+            context: Context,
+            title: String? = null,
+            url: String,
+            sourceOrigin: String? = null,
+            sourceName: String? = null,
+            sourceType: Int? = null,
+            sourceVerificationEnable: Boolean = false,
+            refetchAfterSuccess: Boolean = true,
+            html: String? = null,
+        ): Intent = MainIntent.createWebViewIntent(
+            context, title, url, sourceOrigin, sourceName, sourceType,
+            sourceVerificationEnable, refetchAfterSuccess, html,
+        )
+
         fun createBookSourceManageIntent(context: Context) =
         fun createBookSourceManageIntent(context: Context) =
             MainIntent.createBookSourceManageIntent(context)
             MainIntent.createBookSourceManageIntent(context)
 
 

+ 30 - 0
app/src/main/java/io/legado/app/ui/main/MainIntent.kt

@@ -25,6 +25,14 @@ object MainIntent {
     const val EXTRA_SOURCE_LOGIN_TYPE = "source_login_type"
     const val EXTRA_SOURCE_LOGIN_TYPE = "source_login_type"
     const val EXTRA_SOURCE_LOGIN_KEY = "source_login_key"
     const val EXTRA_SOURCE_LOGIN_KEY = "source_login_key"
     const val EXTRA_EXPLORE_URL = "exploreUrl"
     const val EXTRA_EXPLORE_URL = "exploreUrl"
+    const val EXTRA_WEB_VIEW_TITLE = "title"
+    const val EXTRA_WEB_VIEW_URL = "url"
+    const val EXTRA_WEB_VIEW_SOURCE_ORIGIN = "sourceOrigin"
+    const val EXTRA_WEB_VIEW_SOURCE_NAME = "sourceName"
+    const val EXTRA_WEB_VIEW_SOURCE_TYPE = "sourceType"
+    const val EXTRA_WEB_VIEW_VERIFICATION = "sourceVerificationEnable"
+    const val EXTRA_WEB_VIEW_REFETCH = "refetchAfterSuccess"
+    const val EXTRA_WEB_VIEW_HTML = "html"
 
 
     const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
     const val EXTRA_RSS_SOURCE_URL = "extra_rss_source_url"
     const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
     const val EXTRA_RSS_SORT_URL = "extra_rss_sort_url"
@@ -63,6 +71,28 @@ object MainIntent {
         putExtra(EXTRA_BOOK_URL, bookUrl)
         putExtra(EXTRA_BOOK_URL, bookUrl)
     }
     }
 
 
+    fun createWebViewIntent(
+        context: Context,
+        title: String? = null,
+        url: String,
+        sourceOrigin: String? = null,
+        sourceName: String? = null,
+        sourceType: Int? = null,
+        sourceVerificationEnable: Boolean = false,
+        refetchAfterSuccess: Boolean = true,
+        html: String? = null,
+    ): Intent = createLauncherIntent(context).apply {
+        putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_WEB_VIEW)
+        putExtra(EXTRA_WEB_VIEW_TITLE, title)
+        putExtra(EXTRA_WEB_VIEW_URL, url)
+        putExtra(EXTRA_WEB_VIEW_SOURCE_ORIGIN, sourceOrigin)
+        putExtra(EXTRA_WEB_VIEW_SOURCE_NAME, sourceName)
+        sourceType?.let { putExtra(EXTRA_WEB_VIEW_SOURCE_TYPE, it) }
+        putExtra(EXTRA_WEB_VIEW_VERIFICATION, sourceVerificationEnable)
+        putExtra(EXTRA_WEB_VIEW_REFETCH, refetchAfterSuccess)
+        putExtra(EXTRA_WEB_VIEW_HTML, html)
+    }
+
     fun createBookSourceManageIntent(context: Context): Intent =
     fun createBookSourceManageIntent(context: Context): Intent =
         createLauncherIntent(context).apply {
         createLauncherIntent(context).apply {
             putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_SOURCE_MANAGE)
             putExtra(EXTRA_START_ROUTE, MainRouteConst.ROUTE_BOOK_SOURCE_MANAGE)

+ 26 - 6
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -64,6 +64,7 @@ import io.legado.app.ui.book.knowledge.deleteCharacterAvatar
 import io.legado.app.ui.book.knowledge.saveCharacterAvatar
 import io.legado.app.ui.book.knowledge.saveCharacterAvatar
 import io.legado.app.ui.book.manage.BookshelfManageRouteScreen
 import io.legado.app.ui.book.manage.BookshelfManageRouteScreen
 import io.legado.app.ui.book.read.ReadBookController
 import io.legado.app.ui.book.read.ReadBookController
+import io.legado.app.ui.book.read.ReadBookInitRequest
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookRouteScreen
 import io.legado.app.ui.book.read.ReadBookRouteScreen
 import io.legado.app.ui.book.read.ReadBookViewModel
 import io.legado.app.ui.book.read.ReadBookViewModel
@@ -86,6 +87,8 @@ import io.legado.app.ui.book.source.debug.BookSourceDebugViewModel
 import io.legado.app.ui.book.source.edit.BookSourceEditRoute
 import io.legado.app.ui.book.source.edit.BookSourceEditRoute
 import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
 import io.legado.app.ui.book.source.edit.BookSourceEditViewModel
 import io.legado.app.ui.book.source.manage.BookSourceRouteScreen
 import io.legado.app.ui.book.source.manage.BookSourceRouteScreen
+import io.legado.app.ui.browser.WebViewModel
+import io.legado.app.ui.browser.WebViewRouteScreen
 import io.legado.app.ui.config.ConfigNavScreen
 import io.legado.app.ui.config.ConfigNavScreen
 import io.legado.app.ui.config.ai.AiConfigRouteScreen
 import io.legado.app.ui.config.ai.AiConfigRouteScreen
 import io.legado.app.ui.config.ai.AiModelEditRouteScreen
 import io.legado.app.ui.config.ai.AiModelEditRouteScreen
@@ -141,6 +144,25 @@ fun MainActivity.mainEntryProvider(
     onNavigateToRoute: (NavKey) -> Unit,
     onNavigateToRoute: (NavKey) -> Unit,
     onNavigateBack: () -> Unit,
     onNavigateBack: () -> Unit,
 ) = entryProvider {
 ) = entryProvider {
+    entry<MainRouteWebView> { route ->
+        val viewModel = koinViewModel<WebViewModel>(
+            key = "WebView:${route.url}:${route.sourceOrigin}:${route.sourceVerificationEnable}",
+        )
+        WebViewRouteScreen(
+            intent = Intent().apply {
+                putExtra("title", route.title)
+                putExtra("url", route.url)
+                putExtra("sourceOrigin", route.sourceOrigin)
+                putExtra("sourceName", route.sourceName)
+                route.sourceType?.let { putExtra("sourceType", it) }
+                putExtra("sourceVerificationEnable", route.sourceVerificationEnable)
+                putExtra("refetchAfterSuccess", route.refetchAfterSuccess)
+                putExtra("html", route.html)
+            },
+            viewModel = viewModel,
+            onFinish = onNavigateBack,
+        )
+    }
     entry<MainRouteSourceLogin>(
     entry<MainRouteSourceLogin>(
         metadata = ModalOverlaySceneStrategy.modalOverlay(),
         metadata = ModalOverlaySceneStrategy.modalOverlay(),
     ) { route ->
     ) { route ->
@@ -545,11 +567,9 @@ fun MainActivity.mainEntryProvider(
             readBookViewModel.prepareCachedChapterFallback(route.bookUrl, route.chapterChanged)
             readBookViewModel.prepareCachedChapterFallback(route.bookUrl, route.chapterChanged)
         }
         }
         val lifecycleOwner = LocalLifecycleOwner.current
         val lifecycleOwner = LocalLifecycleOwner.current
-        val readIntent = remember(route) {
-            MainActivity.createReadBookIntent(
-                context = this@mainEntryProvider,
+        val initRequest = remember(route) {
+            ReadBookInitRequest(
                 bookUrl = route.bookUrl,
                 bookUrl = route.bookUrl,
-                readAloud = route.readAloud,
                 inBookshelf = route.inBookshelf,
                 inBookshelf = route.inBookshelf,
                 chapterChanged = route.chapterChanged,
                 chapterChanged = route.chapterChanged,
             )
             )
@@ -637,8 +657,8 @@ fun MainActivity.mainEntryProvider(
         LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
         LaunchedEffect(route, readBookViewModel, lifecycleOwner) {
             effectsReady.await()
             effectsReady.await()
             collectorReady[0] = true
             collectorReady[0] = true
-            readBookViewModel.initReadBookConfig(readIntent)
-            readBookViewModel.initData(readIntent) {
+            readBookViewModel.initReadBookConfig(initRequest)
+            readBookViewModel.initData(initRequest) {
                 readBookViewModel.markJustInitData()
                 readBookViewModel.markJustInitData()
                 controller.onRouteInitialized()
                 controller.onRouteInitialized()
                 if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
                 if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {

+ 13 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -17,6 +17,18 @@ data class MainRouteSourceLogin(
     val bookUrl: String? = null,
     val bookUrl: String? = null,
 ) : MainRoute
 ) : MainRoute
 
 
+@Serializable
+data class MainRouteWebView(
+    val title: String? = null,
+    val url: String,
+    val sourceOrigin: String? = null,
+    val sourceName: String? = null,
+    val sourceType: Int? = null,
+    val sourceVerificationEnable: Boolean = false,
+    val refetchAfterSuccess: Boolean = true,
+    val html: String? = null,
+) : MainRoute
+
 @Serializable
 @Serializable
 data object MainRouteBookSourceManage : MainRoute
 data object MainRouteBookSourceManage : MainRoute
 
 
@@ -215,6 +227,7 @@ data object MainRouteAbout : MainRoute
 object MainRouteConst {
 object MainRouteConst {
     const val ROUTE_MAIN = "main"
     const val ROUTE_MAIN = "main"
     const val ROUTE_SOURCE_LOGIN = "source/login"
     const val ROUTE_SOURCE_LOGIN = "source/login"
+    const val ROUTE_WEB_VIEW = "web/view"
     const val ROUTE_BOOK_SOURCE_MANAGE = "source/book/manage"
     const val ROUTE_BOOK_SOURCE_MANAGE = "source/book/manage"
     const val ROUTE_BOOK_SOURCE_EDIT = "source/book/edit"
     const val ROUTE_BOOK_SOURCE_EDIT = "source/book/edit"
     const val ROUTE_RSS_SOURCE_MANAGE = "source/rss/manage"
     const val ROUTE_RSS_SOURCE_MANAGE = "source/rss/manage"

+ 23 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -34,6 +34,8 @@ object MainNavigator {
                 backStack.add(route)
                 backStack.add(route)
             }
             }
 
 
+            is MainRouteWebView -> backStack.add(route)
+
             MainRouteBookSourceManage,
             MainRouteBookSourceManage,
             is MainRouteBookSourceEdit,
             is MainRouteBookSourceEdit,
             MainRouteRssSourceManage,
             MainRouteRssSourceManage,
@@ -332,6 +334,27 @@ object MainNavigator {
                 bookUrl = intent?.getStringExtra(MainIntent.EXTRA_BOOK_URL),
                 bookUrl = intent?.getStringExtra(MainIntent.EXTRA_BOOK_URL),
             )
             )
 
 
+            MainRouteConst.ROUTE_WEB_VIEW -> intent?.getStringExtra(MainIntent.EXTRA_WEB_VIEW_URL)
+                ?.takeIf { it.isNotBlank() }
+                ?.let { url ->
+                    MainRouteWebView(
+                        title = intent.getStringExtra(MainIntent.EXTRA_WEB_VIEW_TITLE),
+                        url = url,
+                        sourceOrigin = intent.getStringExtra(MainIntent.EXTRA_WEB_VIEW_SOURCE_ORIGIN),
+                        sourceName = intent.getStringExtra(MainIntent.EXTRA_WEB_VIEW_SOURCE_NAME),
+                        sourceType = if (intent.hasExtra(MainIntent.EXTRA_WEB_VIEW_SOURCE_TYPE)) {
+                            intent.getIntExtra(MainIntent.EXTRA_WEB_VIEW_SOURCE_TYPE, 0)
+                        } else null,
+                        sourceVerificationEnable = intent.getBooleanExtra(
+                            MainIntent.EXTRA_WEB_VIEW_VERIFICATION, false
+                        ),
+                        refetchAfterSuccess = intent.getBooleanExtra(
+                            MainIntent.EXTRA_WEB_VIEW_REFETCH, true
+                        ),
+                        html = intent.getStringExtra(MainIntent.EXTRA_WEB_VIEW_HTML),
+                    )
+                } ?: MainRouteHome
+
             MainRouteConst.ROUTE_BOOK_SOURCE_MANAGE -> MainRouteBookSourceManage
             MainRouteConst.ROUTE_BOOK_SOURCE_MANAGE -> MainRouteBookSourceManage
             MainRouteConst.ROUTE_BOOK_SOURCE_EDIT -> MainRouteBookSourceEdit(
             MainRouteConst.ROUTE_BOOK_SOURCE_EDIT -> MainRouteBookSourceEdit(
                 intent?.getStringExtra(MainIntent.EXTRA_SOURCE_URL)
                 intent?.getStringExtra(MainIntent.EXTRA_SOURCE_URL)

+ 3 - 2
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -71,7 +71,7 @@ import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.LifecycleRegistry
 import androidx.lifecycle.LifecycleRegistry
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.HazeState
@@ -482,7 +482,8 @@ fun MainScreen(
                                 ),
                                 ),
                                 m3ShowLabel = showLabel,
                                 m3ShowLabel = showLabel,
                                 m3AlwaysShowLabel = alwaysShowLabel,
                                 m3AlwaysShowLabel = alwaysShowLabel,
-                                useCustomIcon = customIconPath.isNotEmpty()
+                                useCustomIcon =
+                                    customIconPath.isNotEmpty() || selectedCustomIconPath.isNotEmpty(),
                             )
                             )
                         }
                         }
                     }
                     }

+ 11 - 3
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -179,9 +179,17 @@ fun BookshelfListItem(
             onLongClick = onLongClick
             onLongClick = onLongClick
         ) {
         ) {
             Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
             Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
-                Box(Modifier
-                    .align(Alignment.CenterVertically)
-                    .width(coverWidth.dp)) {
+                Box(
+                    Modifier
+                        .align(
+                            if (settings.bookshelfListCoverCenter) {
+                                Alignment.CenterVertically
+                            } else {
+                                Alignment.Top
+                            }
+                        )
+                        .width(coverWidth.dp)
+                ) {
                     BookshelfItemCover(coverShadow = coverShadow, cover = cover)
                     BookshelfItemCover(coverShadow = coverShadow, cover = cover)
                 }
                 }
                 Column(
                 Column(

+ 9 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt

@@ -431,6 +431,15 @@ fun BookshelfConfigSheet(
                         Column(
                         Column(
                             verticalArrangement = Arrangement.spacedBy(8.dp)
                             verticalArrangement = Arrangement.spacedBy(8.dp)
                         ) {
                         ) {
+                            CompactSwitchSettingItem(
+                                title = stringResource(R.string.center_cover_vertically),
+                                description = stringResource(R.string.center_cover_vertically_description),
+                                checked = settings.bookshelfListCoverCenter,
+                                color = LegadoTheme.colorScheme.surface,
+                                onCheckedChange = { value ->
+                                    onUpdate { it.copy(bookshelfListCoverCenter = value) }
+                                }
+                            )
                             CompactSwitchSettingItem(
                             CompactSwitchSettingItem(
                                 title = stringResource(R.string.show_latest_chapter),
                                 title = stringResource(R.string.show_latest_chapter),
                                 checked = settings.bookshelfShowLatestChapter,
                                 checked = settings.bookshelfShowLatestChapter,

+ 2 - 2
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt

@@ -52,8 +52,8 @@ import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil.ImageLoader
-import coil.compose.AsyncImage
+import coil3.ImageLoader
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb

+ 2 - 2
app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditRoute.kt

@@ -55,8 +55,8 @@ fun RssSourceEditRoute(
         title = stringResource(R.string.exit),
         title = stringResource(R.string.exit),
         text = stringResource(R.string.exit_no_save),
         text = stringResource(R.string.exit_no_save),
         confirmText = stringResource(R.string.yes),
         confirmText = stringResource(R.string.yes),
-        onConfirm = { viewModel.onIntent(RssSourceEditIntent.DiscardChanges) },
+        onConfirm = { viewModel.onIntent(RssSourceEditIntent.DismissDialog) },
         dismissText = stringResource(R.string.no),
         dismissText = stringResource(R.string.no),
-        onDismiss = { viewModel.onIntent(RssSourceEditIntent.DismissDialog) },
+        onDismiss = { viewModel.onIntent(RssSourceEditIntent.DiscardChanges) },
     )
     )
 }
 }

+ 1 - 1
app/src/main/java/io/legado/app/ui/theme/AppBackground.kt

@@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.blur
 import androidx.compose.ui.draw.blur
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.domain.model.settings.hasBackgroundImage
 import io.legado.app.domain.model.settings.hasBackgroundImage
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject
 
 

+ 8 - 5
app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt

@@ -14,10 +14,12 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.core.graphics.drawable.toBitmap
 import androidx.core.graphics.drawable.toBitmap
 import androidx.core.graphics.scale
 import androidx.core.graphics.scale
-import coil.ImageLoader
-import coil.request.ImageRequest
-import coil.request.SuccessResult
-import coil.size.Size
+import coil3.ImageLoader
+import coil3.asDrawable
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.request.allowHardware
+import coil3.size.Size
 import com.materialkolor.quantize.QuantizerCelebi
 import com.materialkolor.quantize.QuantizerCelebi
 import com.materialkolor.score.Score
 import com.materialkolor.score.Score
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
@@ -45,7 +47,8 @@ suspend fun ImageLoader.extractSeedColor(
     } as? SuccessResult ?: return null
     } as? SuccessResult ?: return null
 
 
     return withContext(Dispatchers.Default) {
     return withContext(Dispatchers.Default) {
-        val bitmap = result.drawable.toSafeBitmap(IMAGE_COLOR_EXTRACT_SIZE_PX)
+        val bitmap = result.image.asDrawable(context.resources)
+            .toSafeBitmap(IMAGE_COLOR_EXTRACT_SIZE_PX)
         Color(bitmap.extractSeedColor())
         Color(bitmap.extractSeedColor())
     }
     }
 }
 }

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/AppContainerBackground.kt

@@ -14,8 +14,8 @@ 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.platform.LocalDensity
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.rememberAsyncImagePainter
-import coil.request.ImageRequest
+import coil3.compose.rememberAsyncImagePainter
+import coil3.request.ImageRequest
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt

@@ -19,13 +19,13 @@ import androidx.compose.ui.draw.blur
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.domain.model.settings.hasBackgroundImage
 import io.legado.app.domain.model.settings.hasBackgroundImage
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
+import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.responsiveHazeSource
 import io.legado.app.ui.theme.responsiveHazeSource
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.MiuixTheme

+ 5 - 6
app/src/main/java/io/legado/app/ui/widget/components/FontSelectSheet.kt

@@ -3,9 +3,6 @@ package io.legado.app.ui.widget.components
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.TextFields
 import androidx.compose.material.icons.filled.TextFields
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -19,6 +16,8 @@ import androidx.documentfile.provider.DocumentFile
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.ProvideAppDensity
 import io.legado.app.ui.theme.ProvideAppDensity
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
+import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isContentScheme
@@ -62,14 +61,14 @@ fun FontSelectSheet(
         startAction = {
         startAction = {
             startAction?.invoke()
             startAction?.invoke()
             if (systemTypefaces != null && onSelectSystemTypeface != null) {
             if (systemTypefaces != null && onSelectSystemTypeface != null) {
-                DropdownMenu(
+                RoundDropdownMenu(
                     expanded = showTypefaceMenu,
                     expanded = showTypefaceMenu,
                     onDismissRequest = { showTypefaceMenu = false },
                     onDismissRequest = { showTypefaceMenu = false },
                 ) {
                 ) {
                     ProvideAppDensity {
                     ProvideAppDensity {
                         systemTypefaces.forEachIndexed { index, name ->
                         systemTypefaces.forEachIndexed { index, name ->
-                            DropdownMenuItem(
-                                text = { Text(name) },
+                            RoundDropdownMenuItem(
+                                text = name,
                                 onClick = {
                                 onClick = {
                                     onSelectSystemTypeface(index)
                                     onSelectSystemTypeface(index)
                                     showTypefaceMenu = false
                                     showTypefaceMenu = false

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/ReorderableConfigList.kt

@@ -34,7 +34,7 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow

+ 26 - 8
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -4,8 +4,10 @@ import androidx.compose.animation.animateContentSize
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.border
 import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -92,26 +94,42 @@ fun SplicedColumnGroup(
                     ),
                     ),
                     cornerRadius = cornerRadius,
                     cornerRadius = cornerRadius,
                 ) {
                 ) {
-                    Column(
+                    Box(
                         modifier = Modifier
                         modifier = Modifier
                             .fillMaxWidth()
                             .fillMaxWidth()
                             .animateContentSize()
                             .animateContentSize()
                             .clip(RoundedCornerShape(cornerRadius))
                             .clip(RoundedCornerShape(cornerRadius))
                     ) {
                     ) {
-                        currentIndex.intValue = 0
-                        items()
+                        Spacer(
+                            modifier = Modifier
+                                .matchParentSize()
+                                .appContainerBackground(AppContainerBackgroundType.Large)
+                        )
+                        Column(modifier = Modifier.fillMaxWidth()) {
+                            currentIndex.intValue = 0
+                            items()
+                        }
                     }
                     }
                 }
                 }
             } else {
             } else {
-                Column(
+                Box(
                     modifier = Modifier
                     modifier = Modifier
                         .fillMaxWidth()
                         .fillMaxWidth()
                         .animateContentSize()
                         .animateContentSize()
-                        .clip(RoundedCornerShape(cornerRadius)),
-                    verticalArrangement = Arrangement.spacedBy(2.dp)
+                        .clip(RoundedCornerShape(cornerRadius))
                 ) {
                 ) {
-                    currentIndex.intValue = 0
-                    items()
+                    Spacer(
+                        modifier = Modifier
+                            .matchParentSize()
+                            .appContainerBackground(AppContainerBackgroundType.Large)
+                    )
+                    Column(
+                        modifier = Modifier.fillMaxWidth(),
+                        verticalArrangement = Arrangement.spacedBy(2.dp)
+                    ) {
+                        currentIndex.intValue = 0
+                        items()
+                    }
                 }
                 }
             }
             }
         }
         }

+ 20 - 99
app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt

@@ -1,7 +1,6 @@
 package io.legado.app.ui.widget.components.card
 package io.legado.app.ui.widget.components.card
 
 
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.border
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
@@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.ExperimentalMaterialApi
 import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material3.CardDefaults
 import androidx.compose.material3.Surface
 import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
@@ -18,13 +16,10 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.graphics.Shape
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.widget.components.AppContainerBackgroundType
 import io.legado.app.ui.widget.components.AppContainerBackgroundType
 import io.legado.app.ui.widget.components.appContainerBackground
 import io.legado.app.ui.widget.components.appContainerBackground
-import top.yukonga.miuix.kmp.utils.PressFeedbackType
-import top.yukonga.miuix.kmp.basic.Card as MiuixCard
 import top.yukonga.miuix.kmp.basic.CardDefaults as MiuixCardDefaults
 import top.yukonga.miuix.kmp.basic.CardDefaults as MiuixCardDefaults
 
 
 @Composable
 @Composable
@@ -57,7 +52,6 @@ private fun BaseCard(
     onClick: (() -> Unit)? = null,
     onClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
-    pressFeedbackType: PressFeedbackType = PressFeedbackType.None,
     containerColor: Color? = null,
     containerColor: Color? = null,
     contentColor: Color? = null,
     contentColor: Color? = null,
     elevation: Dp = 0.dp,
     elevation: Dp = 0.dp,
@@ -90,99 +84,30 @@ private fun BaseCard(
         border
         border
     }
     }
     val resolvedShape = RoundedCornerShape(resolvedCornerRadius)
     val resolvedShape = RoundedCornerShape(resolvedCornerRadius)
-    val decoratedModifier = modifier.then(
-        if (themeSettings.overrideBaseCardBorder && resolvedBorder != null) {
-            Modifier.border(resolvedBorder, resolvedShape)
-        } else {
-            Modifier
-        }
-    )
-
-    if (isTransparent) {
-        val clickableModifier = if (onClick != null || onLongClick != null) {
-            decoratedModifier
-                .clip(resolvedShape)
-                .combinedClickable(
-                    onClick = { onClick?.invoke() },
-                    onLongClick = onLongClick
-                )
-        } else {
-            decoratedModifier.clip(resolvedShape)
-        }
+    val clickableModifier = if (onClick != null || onLongClick != null) {
+        modifier
+            .clip(resolvedShape)
+            .combinedClickable(
+                onClick = { onClick?.invoke() },
+                onLongClick = onLongClick
+            )
+    } else {
+        modifier
+    }
+    Surface(
+        modifier = clickableModifier,
+        shape = resolvedShape,
+        color = if (isTransparent) Color.Transparent else resolvedContainerColor,
+        contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface,
+        tonalElevation = 0.dp,
+        shadowElevation = elevation,
+        border = resolvedBorder
+    ) {
         BaseCardContent(
         BaseCardContent(
-            modifier = clickableModifier,
             shape = resolvedShape,
             shape = resolvedShape,
             useItemBackground = useItemBackground,
             useItemBackground = useItemBackground,
             content = content,
             content = content,
         )
         )
-    } else if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
-        val colors = MiuixCardDefaults.defaultColors(
-            color = resolvedContainerColor,
-            contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface
-        )
-        if (onClick != null) {
-            MiuixCard(
-                modifier = decoratedModifier,
-                cornerRadius = resolvedCornerRadius,
-                pressFeedbackType = pressFeedbackType,
-                showIndication = true,
-                onClick = onClick,
-                onLongPress = onLongClick,
-                content = {
-                    BaseCardContent(
-                        shape = resolvedShape,
-                        useItemBackground = useItemBackground,
-                        content = content,
-                    )
-                },
-                colors = colors
-            )
-        } else {
-            MiuixCard(
-                modifier = decoratedModifier,
-                cornerRadius = resolvedCornerRadius,
-                content = {
-                    BaseCardContent(
-                        shape = resolvedShape,
-                        useItemBackground = useItemBackground,
-                        content = content,
-                    )
-                },
-                colors = colors
-            )
-        }
-    } else {
-        val colors = CardDefaults.cardColors(
-            containerColor = resolvedContainerColor,
-            contentColor = contentColor ?: LegadoTheme.colorScheme.onSecondaryContainer,
-            disabledContainerColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f),
-            disabledContentColor = LegadoTheme.colorScheme.onSecondaryContainer.copy(alpha = alpha * 0.38f)
-        )
-        val clickableModifier = if (onClick != null || onLongClick != null) {
-            modifier
-                .clip(resolvedShape)
-                .combinedClickable(
-                    onClick = { onClick?.invoke() },
-                    onLongClick = onLongClick
-                )
-        } else {
-            modifier
-        }
-        Surface(
-            modifier = clickableModifier,
-            shape = resolvedShape,
-            color = colors.containerColor,
-            contentColor = colors.contentColor,
-            tonalElevation = 0.dp,
-            shadowElevation = elevation,
-            border = resolvedBorder
-        ) {
-            BaseCardContent(
-                shape = resolvedShape,
-                useItemBackground = useItemBackground,
-                content = content,
-            )
-        }
     }
     }
 }
 }
 
 
@@ -193,7 +118,6 @@ fun GlassCard(
     onClick: (() -> Unit)? = null,
     onClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
-    pressFeedbackType: PressFeedbackType = PressFeedbackType.None,
     containerColor: Color? = null,
     containerColor: Color? = null,
     contentColor: Color? = null,
     contentColor: Color? = null,
     elevation: Dp = 0.dp,
     elevation: Dp = 0.dp,
@@ -205,7 +129,6 @@ fun GlassCard(
         onClick = onClick,
         onClick = onClick,
         onLongClick = onLongClick,
         onLongClick = onLongClick,
         cornerRadius = cornerRadius,
         cornerRadius = cornerRadius,
-        pressFeedbackType = pressFeedbackType,
         containerColor = containerColor,
         containerColor = containerColor,
         contentColor = contentColor,
         contentColor = contentColor,
         elevation = elevation,
         elevation = elevation,
@@ -223,7 +146,6 @@ fun NormalCard(
     onClick: (() -> Unit)? = null,
     onClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     onLongClick: (() -> Unit)? = null,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
     cornerRadius: Dp = MiuixCardDefaults.CornerRadius,
-    pressFeedbackType: PressFeedbackType = PressFeedbackType.None,
     containerColor: Color? = null,
     containerColor: Color? = null,
     contentColor: Color? = null,
     contentColor: Color? = null,
     elevation: Dp = 0.dp,
     elevation: Dp = 0.dp,
@@ -235,7 +157,6 @@ fun NormalCard(
         onClick = onClick,
         onClick = onClick,
         onLongClick = onLongClick,
         onLongClick = onLongClick,
         cornerRadius = cornerRadius,
         cornerRadius = cornerRadius,
-        pressFeedbackType = pressFeedbackType,
         containerColor = containerColor,
         containerColor = containerColor,
         contentColor = contentColor,
         contentColor = contentColor,
         elevation = elevation,
         elevation = elevation,

+ 2 - 2
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -43,8 +43,8 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.core.graphics.withSave
 import androidx.core.graphics.withSave
-import coil.compose.AsyncImage
-import coil.request.ImageRequest
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverBlurBackdrop.kt

@@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.lerp
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.size.Size
+import coil3.size.Size
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 
 
 @Composable
 @Composable

+ 5 - 3
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverImageRequest.kt

@@ -1,7 +1,9 @@
 package io.legado.app.ui.widget.components.image.cover
 package io.legado.app.ui.widget.components.image.cover
 
 
 import android.content.Context
 import android.content.Context
-import coil.request.ImageRequest
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import io.legado.app.help.coil.CoverExtras
 
 
 fun buildCoverImageRequest(
 fun buildCoverImageRequest(
     context: Context,
     context: Context,
@@ -20,9 +22,9 @@ fun buildCoverImageRequest(
                 memoryCacheKey(memoryCacheKey)
                 memoryCacheKey(memoryCacheKey)
                 placeholderMemoryCacheKey(memoryCacheKey)
                 placeholderMemoryCacheKey(memoryCacheKey)
             }
             }
+            extras[CoverExtras.SourceOrigin] = sourceOrigin
+            extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
         }
         }
-        .setParameter("sourceOrigin", sourceOrigin)
-        .setParameter("loadOnlyWifi", loadOnlyWifi)
         .apply(configure)
         .apply(configure)
         .build()
         .build()
 }
 }

+ 10 - 6
app/src/main/java/io/legado/app/ui/widget/components/image/sourceIcon/SourceIcon.kt

@@ -23,9 +23,11 @@ import androidx.compose.ui.unit.dp
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
-import coil.ImageLoader
-import coil.compose.AsyncImage
-import coil.request.ImageRequest
+import coil3.ImageLoader
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
+import coil3.request.crossfade
+import io.legado.app.help.coil.CoverExtras
 import org.koin.compose.koinInject
 import org.koin.compose.koinInject
 
 
 /**
 /**
@@ -59,8 +61,10 @@ fun SourceIcon(
         ImageRequest.Builder(context)
         ImageRequest.Builder(context)
             .data(path)
             .data(path)
             .crossfade(true)
             .crossfade(true)
-            .setParameter("sourceOrigin", sourceOrigin)
-            .setParameter("loadOnlyWifi", loadOnlyWifi)
+            .apply {
+                extras[CoverExtras.SourceOrigin] = sourceOrigin
+                extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
+            }
             .build()
             .build()
     }
     }
 
 
@@ -109,7 +113,7 @@ fun SourceIcon(
                 modifier = Modifier.fillMaxSize(),
                 modifier = Modifier.fillMaxSize(),
                 onSuccess = { state ->
                 onSuccess = { state ->
                     imageLoaded = true
                     imageLoaded = true
-                    animatedDrawable = (state.result.drawable as? Animatable)?.also { drawable ->
+                    animatedDrawable = (state.result.image as? Animatable)?.also { drawable ->
                         if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
                         if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
                             if (!drawable.isRunning) drawable.start()
                             if (!drawable.isRunning) drawable.start()
                         } else {
                         } else {

+ 31 - 6
app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt

@@ -55,6 +55,7 @@ fun AppModalBottomSheet(
     animateContentSize: Boolean = true,
     animateContentSize: Boolean = true,
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     contentPaddingEnabled: Boolean = true,
     contentPaddingEnabled: Boolean = true,
+    sheetGesturesEnabled: Boolean = true,
     content: @Composable ColumnScope.() -> Unit
     content: @Composable ColumnScope.() -> Unit
 ) {
 ) {
     val colorScheme = LocalLegadoThemeColors.current.colorScheme
     val colorScheme = LocalLegadoThemeColors.current.colorScheme
@@ -72,7 +73,10 @@ fun AppModalBottomSheet(
                     ProvideAppDensity {
                     ProvideAppDensity {
                         ProvideAppContentColor(sheetContentColor) {
                         ProvideAppContentColor(sheetContentColor) {
                             CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
                             CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
-                                action()
+                                Box(
+                                    modifier = if (contentPaddingEnabled) Modifier
+                                    else Modifier.padding(start = 16.dp),
+                                ) { action() }
                             }
                             }
                         }
                         }
                     }
                     }
@@ -83,7 +87,10 @@ fun AppModalBottomSheet(
                     ProvideAppDensity {
                     ProvideAppDensity {
                         ProvideAppContentColor(sheetContentColor) {
                         ProvideAppContentColor(sheetContentColor) {
                             CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
                             CompositionLocalProvider(LocalUseMiuixWindowPopup provides true) {
-                                action()
+                                Box(
+                                    modifier = if (contentPaddingEnabled) Modifier
+                                    else Modifier.padding(end = 16.dp),
+                                ) { action() }
                             }
                             }
                         }
                         }
                     }
                     }
@@ -94,7 +101,6 @@ fun AppModalBottomSheet(
             dragHandleColor = sheetDragHandleColor,
             dragHandleColor = sheetDragHandleColor,
             onDismissRequest = onDismissRequest,
             onDismissRequest = onDismissRequest,
             enableWindowDim = true,
             enableWindowDim = true,
-            allowDismiss = true
         ) {
         ) {
             ProvideAppDensity {
             ProvideAppDensity {
                 ProvideAppContentColor(sheetContentColor) {
                 ProvideAppContentColor(sheetContentColor) {
@@ -141,7 +147,8 @@ fun AppModalBottomSheet(
                     containerColor = sheetContainerColor,
                     containerColor = sheetContainerColor,
                     contentColor = sheetContentColor,
                     contentColor = sheetContentColor,
                     dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) },
                     dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) },
-                    contentWindowInsets = contentWindowInsets
+                    contentWindowInsets = contentWindowInsets,
+                    sheetGesturesEnabled = sheetGesturesEnabled,
                 ) {
                 ) {
                     ProvideAppDensity {
                     ProvideAppDensity {
                         Column(
                         Column(
@@ -171,7 +178,15 @@ fun AppModalBottomSheet(
                                     contentAlignment = Alignment.Center
                                     contentAlignment = Alignment.Center
                                 ) {
                                 ) {
                                     if (startAction != null) {
                                     if (startAction != null) {
-                                        Box(modifier = Modifier.align(Alignment.CenterStart)) {
+                                        Box(
+                                            modifier = Modifier
+                                                .align(Alignment.CenterStart)
+                                                .let {
+                                                    if (contentPaddingEnabled) it else it.padding(
+                                                        start = 16.dp
+                                                    )
+                                                }
+                                        ) {
                                             startAction()
                                             startAction()
                                         }
                                         }
                                     }
                                     }
@@ -189,7 +204,15 @@ fun AppModalBottomSheet(
                                     }
                                     }
 
 
                                     if (endAction != null) {
                                     if (endAction != null) {
-                                        Box(modifier = Modifier.align(Alignment.CenterEnd)) {
+                                        Box(
+                                            modifier = Modifier
+                                                .align(Alignment.CenterEnd)
+                                                .let {
+                                                    if (contentPaddingEnabled) it else it.padding(
+                                                        end = 16.dp
+                                                    )
+                                                }
+                                        ) {
                                             endAction()
                                             endAction()
                                         }
                                         }
                                     }
                                     }
@@ -220,6 +243,7 @@ fun <T> AppModalBottomSheet(
     endAction: @Composable (() -> Unit)? = null,
     endAction: @Composable (() -> Unit)? = null,
     animateContentSize: Boolean = true,
     animateContentSize: Boolean = true,
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
     contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets },
+    sheetGesturesEnabled: Boolean = true,
     content: @Composable ColumnScope.(T) -> Unit
     content: @Composable ColumnScope.(T) -> Unit
 ) {
 ) {
     var cachedData by remember { mutableStateOf(data) }
     var cachedData by remember { mutableStateOf(data) }
@@ -238,6 +262,7 @@ fun <T> AppModalBottomSheet(
         endAction = endAction,
         endAction = endAction,
         animateContentSize = animateContentSize,
         animateContentSize = animateContentSize,
         contentWindowInsets = contentWindowInsets,
         contentWindowInsets = contentWindowInsets,
+        sheetGesturesEnabled = sheetGesturesEnabled,
         content = {
         content = {
             if (currentData != null) {
             if (currentData != null) {
                 content(currentData)
                 content(currentData)

+ 52 - 1
app/src/main/java/io/legado/app/ui/widget/components/navigation/AppNavigationBar.kt

@@ -3,7 +3,9 @@ package io.legado.app.ui.widget.components.navigation
 import androidx.compose.foundation.background
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.interaction.MutableInteractionSource
 import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
@@ -23,11 +25,14 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.graphics.vector.ImageVector
 import androidx.compose.ui.layout.HorizontalAlignmentLine
 import androidx.compose.ui.layout.HorizontalAlignmentLine
 import androidx.compose.ui.layout.Measured
 import androidx.compose.ui.layout.Measured
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
 import io.legado.app.domain.model.settings.customColors
 import io.legado.app.domain.model.settings.customColors
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.theme.LocalAppUiConfiguration
+import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.theme.regularHazeEffect
 import io.legado.app.ui.theme.regularHazeEffect
 import io.legado.app.ui.widget.components.GlassDefaults
 import io.legado.app.ui.widget.components.GlassDefaults
@@ -151,6 +156,15 @@ fun RowScope.AppNavigationBarItem(
             }
             }
             m3Icon()
             m3Icon()
         }
         }
+    } else if (isMiuix && useCustomIcon) {
+        MiuixCustomNavigationBarItem(
+            selected = selected,
+            onClick = onClick,
+            modifier = modifier,
+            labelString = labelString,
+            showLabel = m3ShowLabel && (m3AlwaysShowLabel || selected),
+            icon = m3Icon,
+        )
     } else if (isMiuix) {
     } else if (isMiuix) {
         MiuixNavigationBarItem(
         MiuixNavigationBarItem(
             selected = selected,
             selected = selected,
@@ -174,3 +188,40 @@ fun RowScope.AppNavigationBarItem(
         )
         )
     }
     }
 }
 }
+
+@Composable
+private fun RowScope.MiuixCustomNavigationBarItem(
+    selected: Boolean,
+    onClick: () -> Unit,
+    modifier: Modifier,
+    labelString: String,
+    showLabel: Boolean,
+    icon: @Composable () -> Unit,
+) {
+    val itemColor = MiuixTheme.colorScheme.onSurfaceContainer.let { color ->
+        if (selected) color else color.copy(alpha = 0.4f)
+    }
+    Column(
+        modifier = modifier
+            .height(64.dp)
+            .weight(1f)
+            .clickable(
+                interactionSource = remember { MutableInteractionSource() },
+                indication = null,
+                onClick = onClick,
+            ),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+    ) {
+        icon()
+        if (showLabel) {
+            AnimatedText(
+                text = labelString,
+                color = itemColor,
+                fontSize = 12.sp,
+                fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
+                textAlign = TextAlign.Center,
+            )
+        }
+    }
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt

@@ -59,7 +59,7 @@ import androidx.compose.ui.state.ToggleableState
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
+import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AccentColorButton
 import io.legado.app.ui.widget.components.AccentColorButton

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

@@ -361,6 +361,16 @@
     <string name="header_padding">页眉边距</string>
     <string name="header_padding">页眉边距</string>
     <string name="footer_padding">页脚边距</string>
     <string name="footer_padding">页脚边距</string>
     <string name="check_book_source">校验书源</string>
     <string name="check_book_source">校验书源</string>
+    <string name="book_source_check_completed">校验完成:成功 %1$d,失败 %2$d</string>
+    <string name="book_source_check_cancelled">校验已停止:成功 %1$d,失败 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校验</string>
+    <string name="book_source_check_running">正在校验</string>
+    <string name="book_source_check_succeeded">校验成功</string>
+    <string name="book_source_check_failed">校验失败:%1$s</string>
+    <string name="book_source_check_source_missing">校验失败:书源不存在</string>
+    <string name="book_source_check_save_failed">校验失败:无法保存结果,%1$s</string>
+    <string name="book_source_check_incomplete">校验失败:任务未完成</string>
+    <string name="book_source_check_item_cancelled">校验已取消</string>
     <string name="check_select_source">校验所选</string>
     <string name="check_select_source">校验所选</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
     <string name="tts_fix">请安装并选择中文 TTS!</string>
     <string name="tts_fix">请安装并选择中文 TTS!</string>
@@ -2330,6 +2340,8 @@
     <string name="night_card_bg_color">夜间卡片背景颜色</string>
     <string name="night_card_bg_color">夜间卡片背景颜色</string>
     <string name="compact_details">精简详情</string>
     <string name="compact_details">精简详情</string>
     <string name="show_more_info">显示更多信息</string>
     <string name="show_more_info">显示更多信息</string>
+    <string name="center_cover_vertically">封面垂直居中</string>
+    <string name="center_cover_vertically_description">关闭时,封面顶部对齐。</string>
     <string name="show_synopsis_below_content">简介显示在封面和信息下方</string>
     <string name="show_synopsis_below_content">简介显示在封面和信息下方</string>
     <string name="show_latest_chapter">显示最新章节</string>
     <string name="show_latest_chapter">显示最新章节</string>
     <string name="show_synopsis">显示简介</string>
     <string name="show_synopsis">显示简介</string>

+ 12 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -303,6 +303,16 @@
     <string name="padding_right">右邊距</string>
     <string name="padding_right">右邊距</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
     <string name="check_book_source">校驗書源</string>
+    <string name="book_source_check_completed">校驗完成:成功 %1$d,失敗 %2$d</string>
+    <string name="book_source_check_cancelled">校驗已停止:成功 %1$d,失敗 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校驗</string>
+    <string name="book_source_check_running">正在校驗</string>
+    <string name="book_source_check_succeeded">校驗成功</string>
+    <string name="book_source_check_failed">校驗失敗:%1$s</string>
+    <string name="book_source_check_source_missing">校驗失敗:書源不存在</string>
+    <string name="book_source_check_save_failed">校驗失敗:無法保存結果,%1$s</string>
+    <string name="book_source_check_incomplete">校驗失敗:任務未完成</string>
+    <string name="book_source_check_item_cancelled">校驗已取消</string>
     <string name="check_select_source">校驗所選</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文 TTS!</string>
     <string name="tts_fix">請安裝並選擇中文 TTS!</string>
@@ -2106,6 +2116,8 @@
     <string name="show_lines_synopsis">顯示簡介 %1$d 行</string>
     <string name="show_lines_synopsis">顯示簡介 %1$d 行</string>
     <string name="show_menu_icon">顯示選單項目圖標</string>
     <string name="show_menu_icon">顯示選單項目圖標</string>
     <string name="show_more_info">顯示更多資訊</string>
     <string name="show_more_info">顯示更多資訊</string>
+    <string name="center_cover_vertically">封面垂直置中</string>
+    <string name="center_cover_vertically_description">關閉時,封面頂部對齊。</string>
     <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_original">顯示原文</string>
     <string name="show_original">顯示原文</string>
     <string name="show_password">顯示密碼</string>
     <string name="show_password">顯示密碼</string>

+ 12 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -305,6 +305,16 @@
     <string name="padding_right">右邊距</string>
     <string name="padding_right">右邊距</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
     <string name="check_book_source">校驗書源</string>
+    <string name="book_source_check_completed">校驗完成:成功 %1$d,失敗 %2$d</string>
+    <string name="book_source_check_cancelled">校驗已停止:成功 %1$d,失敗 %2$d,取消 %3$d</string>
+    <string name="book_source_check_waiting">等待校驗</string>
+    <string name="book_source_check_running">正在校驗</string>
+    <string name="book_source_check_succeeded">校驗成功</string>
+    <string name="book_source_check_failed">校驗失敗:%1$s</string>
+    <string name="book_source_check_source_missing">校驗失敗:書源不存在</string>
+    <string name="book_source_check_save_failed">校驗失敗:無法儲存結果,%1$s</string>
+    <string name="book_source_check_incomplete">校驗失敗:工作未完成</string>
+    <string name="book_source_check_item_cancelled">校驗已取消</string>
     <string name="check_select_source">校驗所選</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文TTS!</string>
     <string name="tts_fix">請安裝並選擇中文TTS!</string>
@@ -2244,6 +2254,8 @@
     <string name="night_card_bg_color">夜間卡片背景色</string>
     <string name="night_card_bg_color">夜間卡片背景色</string>
     <string name="compact_details">精簡詳情</string>
     <string name="compact_details">精簡詳情</string>
     <string name="show_more_info">顯示更多資訊</string>
     <string name="show_more_info">顯示更多資訊</string>
+    <string name="center_cover_vertically">封面垂直置中</string>
+    <string name="center_cover_vertically_description">關閉時,封面頂部對齊。</string>
     <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_synopsis_below_content">簡介顯示在封面和資訊下方</string>
     <string name="show_latest_chapter">顯示最新章節</string>
     <string name="show_latest_chapter">顯示最新章節</string>
     <string name="show_synopsis">顯示簡介</string>
     <string name="show_synopsis">顯示簡介</string>

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

@@ -391,6 +391,16 @@
     <string name="header_padding">Header Padding</string>
     <string name="header_padding">Header Padding</string>
     <string name="footer_padding">Footer Padding</string>
     <string name="footer_padding">Footer Padding</string>
     <string name="check_book_source">Check book sources</string>
     <string name="check_book_source">Check book sources</string>
+    <string name="book_source_check_completed">Check completed: %1$d succeeded, %2$d failed</string>
+    <string name="book_source_check_cancelled">Check stopped: %1$d succeeded, %2$d failed, %3$d cancelled</string>
+    <string name="book_source_check_waiting">Waiting to check</string>
+    <string name="book_source_check_running">Checking</string>
+    <string name="book_source_check_succeeded">Check succeeded</string>
+    <string name="book_source_check_failed">Check failed: %1$s</string>
+    <string name="book_source_check_source_missing">Check failed: source no longer exists</string>
+    <string name="book_source_check_save_failed">Check failed: unable to save result, %1$s</string>
+    <string name="book_source_check_incomplete">Check failed: task did not finish</string>
+    <string name="book_source_check_item_cancelled">Check cancelled</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
     <string name="tts_fix">Please install and select Chinese TTS!</string>
     <string name="tts_fix">Please install and select Chinese TTS!</string>
@@ -2354,6 +2364,8 @@
     <string name="night_card_bg_color">Night Card Background Color</string>
     <string name="night_card_bg_color">Night Card Background Color</string>
     <string name="compact_details">Compact Details</string>
     <string name="compact_details">Compact Details</string>
     <string name="show_more_info">Show More Info</string>
     <string name="show_more_info">Show More Info</string>
+    <string name="center_cover_vertically">Center Cover Vertically</string>
+    <string name="center_cover_vertically_description">When disabled, the cover is aligned to the top.</string>
     <string name="show_synopsis_below_content">Show Synopsis Below Cover and Details</string>
     <string name="show_synopsis_below_content">Show Synopsis Below Cover and Details</string>
     <string name="show_latest_chapter">Show Latest Chapter</string>
     <string name="show_latest_chapter">Show Latest Chapter</string>
     <string name="show_synopsis">Show Synopsis</string>
     <string name="show_synopsis">Show Synopsis</string>

+ 5 - 2
app/src/test/java/io/legado/app/data/repository/BookshelfSettingsMappingTest.kt

@@ -8,8 +8,8 @@ import org.junit.Test
 class BookshelfSettingsMappingTest {
 class BookshelfSettingsMappingTest {
 
 
     @Test
     @Test
-    fun `Bookshelf 47 键写读映射逐字段对应`() {
-        assertEquals(47, BookshelfSettings().toPrefMap().size)
+    fun `Bookshelf 48 键写读映射逐字段对应`() {
+        assertEquals(48, BookshelfSettings().toPrefMap().size)
         bookshelfMappingSamples().forEach { expected ->
         bookshelfMappingSamples().forEach { expected ->
             assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
             assertEquals(expected.expectedPrefMap(), expected.toPrefMap())
             assertEquals(
             assertEquals(
@@ -66,6 +66,7 @@ private fun bookshelfMappingSamples(): List<BookshelfSettings> {
         bookshelfFolderLayoutListLandscape = 117,
         bookshelfFolderLayoutListLandscape = 117,
         bookshelfGridLayout = 118,
         bookshelfGridLayout = 118,
         bookshelfLayoutCompact = false,
         bookshelfLayoutCompact = false,
+        bookshelfListCoverCenter = false,
         bookshelfListIntroBelowContent = false,
         bookshelfListIntroBelowContent = false,
         bookshelfShowDivider = false,
         bookshelfShowDivider = false,
         bookshelfTitleSmallFont = false,
         bookshelfTitleSmallFont = false,
@@ -98,6 +99,7 @@ private fun bookshelfMappingSamples(): List<BookshelfSettings> {
         base.copy(showBookshelfFastScroller = true),
         base.copy(showBookshelfFastScroller = true),
         base.copy(shouldShowExpandButton = true),
         base.copy(shouldShowExpandButton = true),
         base.copy(bookshelfLayoutCompact = true),
         base.copy(bookshelfLayoutCompact = true),
+        base.copy(bookshelfListCoverCenter = true),
         base.copy(bookshelfListIntroBelowContent = true),
         base.copy(bookshelfListIntroBelowContent = true),
         base.copy(bookshelfShowDivider = true),
         base.copy(bookshelfShowDivider = true),
         base.copy(bookshelfTitleSmallFont = true),
         base.copy(bookshelfTitleSmallFont = true),
@@ -141,6 +143,7 @@ private fun BookshelfSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfFolderLayoutListLandscape to bookshelfFolderLayoutListLandscape,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfGridLayout to bookshelfGridLayout,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
     PreferKey.bookshelfLayoutCompact to bookshelfLayoutCompact,
+    PreferKey.bookshelfListCoverCenter to bookshelfListCoverCenter,
     PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfListIntroBelowContent to bookshelfListIntroBelowContent,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfShowDivider to bookshelfShowDivider,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,
     PreferKey.bookshelfTitleSmallFont to bookshelfTitleSmallFont,

+ 61 - 0
app/src/test/java/io/legado/app/ui/dict/DictHtmlParserTest.kt

@@ -0,0 +1,61 @@
+package io.legado.app.ui.dict
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class DictHtmlParserTest {
+
+    @Test
+    fun preservesStyleAndTextAcrossInlineImage() {
+        val document = DictHtmlParser.parse(
+            """<span style="color:#f00;text-decoration:underline">拼音<img src="tone.png">释义</span>"""
+        )
+
+        val content = document.paragraphs.single().content
+        assertEquals("拼音", (content[0] as DictHtmlInline.Text).value)
+        assertEquals("tone.png", (content[1] as DictHtmlInline.Image).source)
+        assertEquals("释义", (content[2] as DictHtmlInline.Text).value)
+        listOf(content[0], content[2]).filterIsInstance<DictHtmlInline.Text>().forEach {
+            assertEquals("#f00", it.style.color)
+            assertTrue(it.style.underline)
+        }
+    }
+
+    @Test
+    fun keepsNestedLinkOnTextAndImage() {
+        val content = DictHtmlParser.parse(
+            """<a href="https://example.com"><b>词条</b><img src="icon.png"></a>"""
+        ).paragraphs.single().content
+
+        val text = content[0] as DictHtmlInline.Text
+        val image = content[1] as DictHtmlInline.Image
+        assertTrue(text.style.bold)
+        assertEquals("https://example.com", text.link)
+        assertEquals("https://example.com", image.link)
+    }
+
+    @Test
+    fun parsesLegacyFontAndInlineCss() {
+        val text = DictHtmlParser.parse(
+            """<font color="blue"><u>A</u></font><span style="background-color:#123456;font-style:italic">B</span>"""
+        ).paragraphs.single().content.filterIsInstance<DictHtmlInline.Text>()
+
+        assertEquals("blue", text[0].style.color)
+        assertTrue(text[0].style.underline)
+        assertEquals("#123456", text[1].style.backgroundColor)
+        assertTrue(text[1].style.italic)
+    }
+
+    @Test
+    fun malformedHtmlRetainsReadableContent() {
+        val document = DictHtmlParser.parse("<div>前<span style='color:red'>中<img src=x>后</div>")
+
+        val content = document.paragraphs.single().content
+        assertEquals(
+            "前中",
+            content.filterIsInstance<DictHtmlInline.Text>().take(2).joinToString("") { it.value })
+        assertEquals("x", content.filterIsInstance<DictHtmlInline.Image>().single().source)
+        assertEquals("后", content.filterIsInstance<DictHtmlInline.Text>().last().value)
+    }
+}

+ 5 - 4
gradle/libs.versions.toml

@@ -6,7 +6,7 @@ adaptiveLayout = "1.3.0-rc01"
 adaptiveNavigation = "1.3.0-rc01"
 adaptiveNavigation = "1.3.0-rc01"
 animation = "1.11.4"
 animation = "1.11.4"
 biometric = "1.4.0-alpha07"
 biometric = "1.4.0-alpha07"
-coilCompose = "2.7.0"
+coilCompose = "3.5.0"
 composeBom = "2026.06.01"
 composeBom = "2026.06.01"
 constraintlayoutCompose = "1.1.1"
 constraintlayoutCompose = "1.1.1"
 coreSplashscreen = "1.2.0"
 coreSplashscreen = "1.2.0"
@@ -143,9 +143,10 @@ koin-android = { group = "io.insert-koin", name = "koin-android" }
 koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
 koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose" }
 koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel" }
 koin-compose-viewmodel = { group = "io.insert-koin", name = "koin-compose-viewmodel" }
 
 
-coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
-coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coilCompose" }
-coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coilCompose" }
+coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
+coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilCompose" }
+coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
+coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilCompose" }
 core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
 core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
 
 
 androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }
 androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }