Kaynağa Gözat

feature/bookinfo-related-books (#1201)

* feature: add translation full content

* feature: add translation full content

* feature: add translation full content

* feature: add translation full content

* feature: add translation full content

* feature:use chunk files replace chunk cache db

* feature:use chunk files replace chunk cache db

* feature:use chunk files replace chunk cache db

* feature: add translation config navigation and refactor translation settings

* feature: 体验优化

* feature: fixed error prompt

* Fix Gson deserialization error for LLM translation classes under R8 obfuscation

* Fix PMD error

* feature: pre-translation

* feature: pre-translation

* 优化

* 优化

* 增加书籍详情页关联书籍推荐功能

* 修复关联书籍"更多"按钮URL模板变量未解析的问题

* 修复发现按钮部分不执行JS的问题

* 优化书籍详情封面长按逻辑

* 阅读界面自定义图标问题

* R8反混淆问题

* 主题保存时保存图片等资源

* 在首页、发现页等长按时,提供了一个查看简短书籍信息的界面与右上角书籍信息角标。现在书籍信息角标在封面设置中显示

* 现在可配置主页图标

* 主页模块配置被遮挡的问题

* 一些优化

* fix: address code review findings (null safety, error propagation, performance)

- TranslateChapterUseCase: wrap execute() in try-catch to prevent task
  stuck in Translating state; propagate actual translation error instead
  of generic "Translation incomplete"; defensive null check on pairs
- DictionaryRepositoryImpl: safe call on pairs for Gson null-bypass
- LlmTranslateRepositoryImpl: safe calls on json.sentences and
  json.choices to prevent NPE from unexpected API responses
- ContentChunker: normalize \r\n to \n before splitting paragraphs
  to handle Windows-formatted text files
- BookInfoViewModel: parallelize related books loading with async/awaitAll
- CoilBookCover: add finalPath as remember key to reset state on reuse
- ThemeImportExport: fallback to filesDir when getExternalFilesDir is null

---------

Co-authored-by: duanhl <duanhl7749@gmail.com>
Kudomaga 5 ay önce
ebeveyn
işleme
db5b18ca3c
53 değiştirilmiş dosya ile 1952 ekleme ve 561 silme
  1. 7 1
      .claude/settings.local.json
  2. 12 1
      app/proguard-rules.pro
  3. 224 0
      app/src/main/assets/web/help/md/relatedBooksHelp.md
  4. 3 0
      app/src/main/java/io/legado/app/data/entities/TranslationCache.kt
  5. 2 1
      app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt
  6. 2 1
      app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt
  7. 9 2
      app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt
  8. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  9. 2 1
      app/src/main/java/io/legado/app/domain/model/ContentChunker.kt
  10. 4 0
      app/src/main/java/io/legado/app/domain/model/HomepageModels.kt
  11. 5 0
      app/src/main/java/io/legado/app/domain/model/TranslationModels.kt
  12. 20 0
      app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt
  13. 107 94
      app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt
  14. 0 6
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  15. 173 2
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  16. 81 4
      app/src/main/java/io/legado/app/model/webBook/WebBook.kt
  17. 45 67
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  18. 7 28
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt
  19. 15 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  20. 28 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  21. 10 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  22. 156 82
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  23. 103 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  24. 9 9
      app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt
  25. 15 2
      app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt
  26. 27 0
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  27. 8 0
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  28. 2 0
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt
  29. 5 0
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt
  30. 18 1
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
  31. 11 18
      app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
  32. 8 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt
  33. 11 15
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  34. 2 0
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  35. 6 0
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  36. 8 1
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt
  37. 58 17
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt
  38. 84 4
      app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt
  39. 2 2
      app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt
  40. 62 17
      app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt
  41. 61 17
      app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt
  42. 10 9
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt
  43. 66 21
      app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt
  44. 30 9
      app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt
  45. 57 16
      app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt
  46. 13 5
      app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
  47. 38 5
      app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt
  48. 186 0
      app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt
  49. 25 5
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt
  50. 90 86
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  51. 11 11
      app/src/main/java/io/legado/app/ui/widget/components/modalBottomSheet/AppModalBottomSheet.kt
  52. 6 0
      app/src/main/res/values-zh-rCN/strings.xml
  53. 6 0
      app/src/main/res/values/strings.xml

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

@@ -102,7 +102,13 @@
       "Bash(Select-Object Name)",
       "PowerShell(Get-ChildItem -Path \"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\" -Directory -Depth 1 | ForEach-Object { $_.FullName.Replace\\(\"D:\\\\AndroidPrj\\\\legado-with-MD3\\\\app\\\\src\\\\main\\\\java\\\\io\\\\legado\\\\app\\\\\", \"\"\\) })",
       "Bash(gh pr *)",
-      "PowerShell(Get-Content *)"
+      "PowerShell(Get-Content *)",
+      "Bash(python *)",
+      "Bash(C:\\\\Users\\\\www13\\\\.claude\\\\projects\\\\D--AndroidPrj-legado-with-MD3\\\\c1441549-f22e-480f-8eaf-d830b19148d1\\\\tool-results\\\\guangyu_source.json)",
+      "Bash(node -e ' *)",
+      "Bash(node *)",
+      "Bash(Select-String -Pattern \"BUILD|error|Error\")",
+      "Bash(Select-Object -First 10)"
     ]
   }
 }

+ 12 - 1
app/proguard-rules.pro

@@ -63,7 +63,18 @@
 -keep class **.data.entities.**{*;}
 # Gson反序列化用的数据传输类
 -keep class io.legado.app.model.translation.**{*;}
--keep class io.legado.app.data.entities.TranslationCache{*;}
+-keep class io.legado.app.domain.model.DictPair{*;}
+-keep class io.legado.app.domain.model.BookDictionary{*;}
+-keep class io.legado.app.domain.model.TextChunk{*;}
+-keep class io.legado.app.domain.model.ModuleDef{*;}
+-keep class io.legado.app.domain.model.ModuleItem{*;}
+-keep class io.legado.app.domain.model.CustomSetItem{*;}
+-keep class io.legado.app.data.repository.GoogleTranslateResponse{*;}
+-keep class io.legado.app.data.repository.GoogleSentence{*;}
+-keep class io.legado.app.data.repository.GoogleSpell{*;}
+-keep class io.legado.app.data.repository.OpenAIResponse{*;}
+-keep class io.legado.app.data.repository.OpenAIChoice{*;}
+-keep class io.legado.app.data.repository.OpenAIMessage{*;}
 # hutool-core hutool-crypto
 -keep class
 !cn.hutool.core.util.RuntimeUtil,

+ 224 - 0
app/src/main/assets/web/help/md/relatedBooksHelp.md

@@ -0,0 +1,224 @@
+# 关联书籍 (Related Books) 配置规范
+
+书源的 `ruleBookInfo.relatedBooks` 字段允许开发者声明一组关联书籍模块,用于在书籍详情页底部展示「关联书籍」横滑轮播。支持配置多个模块,每个模块有独立的标题和数据来源,例如「同作者作品」、「读这本书的人还在读」等。
+
+---
+
+## 1. 字段位置
+
+在书源编辑器的 **详情页** 选项卡中,新增了 `relatedBooks` 字段。
+
+```
+详情页 → relatedBooks
+```
+
+对应 JSON 路径:`ruleBookInfo.relatedBooks`
+
+---
+
+## 2. 数据结构
+
+`relatedBooks` 是一个包含多个模块定义对象的 JSON 数组。
+
+### 模块字段
+
+| 字段      | 类型       | 必须 | 说明                                     |
+|:--------|:---------|:---|:---------------------------------------|
+| **key** | `String` | 否  | 模块唯一标识。建议使用 `[a-z0-9_]` 字符。若未提供则使用 title。 |
+| **title** | `String` | 是  | 模块标题,显示在轮播上方。如「同作者作品」。                  |
+| **url** | `String` | 是  | 数据接口 URL。支持模板变量替换。                      |
+
+---
+
+## 3. 工作原理
+
+1. 当用户打开某本书的详情页时,如果该书源配置了 `relatedBooks`,系统会解析 JSON 数组。
+2. 对每个模块,系统将 URL 中的模板变量替换为当前书籍的实际值,然后发起请求。
+3. 返回的数据使用书源的 **发现规则**(`ruleExplore`)进行解析,获取书籍列表。
+4. 解析后的书籍以横滑轮播的形式展示在详情页的操作按钮和书籍简介之间。
+5. 当前查看的书籍会自动从结果中过滤掉,避免重复显示。
+6. 如果某个模块请求失败或返回空列表,该模块会被静默跳过,不影响其他模块和详情页功能。
+
+> **注意**:关联书籍的解析复用的是「发现」规则(`ruleExplore`),而非「详情页」规则(`ruleBookInfo`)。请确保书源的发现规则已正确配置。
+
+---
+
+## 4. URL 语法
+
+URL 支持与 `exploreUrl` 相同的 JS 语法,包括 `@js:` 前缀、`<js></js>` 标签和 `{{...}}` 内嵌表达式。
+
+### JS 上下文中的可用对象
+
+| 对象       | 说明                   | 示例属性                                                                |
+|:---------|:---------------------|:--------------------------------------------------------------------|
+| `book`   | 当前书籍对象               | `book.name`, `book.author`, `book.kind`, `book.bookUrl`, `book.tocUrl`, `book.origin` |
+| `source` | 当前书源对象               | `source.bookSourceUrl`, `source.getVariable()` 等                      |
+| `cookie` | Cookie 存储             | `cookie.getKey(domain, key)`                                        |
+| `page`   | 页码(固定为 1)            | `page`                                                              |
+| `java`   | JS 扩展工具(`AnalyzeUrl`) | `java.ajax()`, `java.log()` 等                                       |
+
+### 简单模板语法
+
+对于简单的 URL,可以直接使用 `{{book.属性名}}` 语法,值会自动进行 URL 编码:
+
+```
+https://example.com/search?keyword={{book.author}}&name={{book.name}}
+```
+
+### `@js:` 表达式
+
+对于需要逻辑处理的 URL,使用 `@js:` 前缀:
+
+```
+@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8")
+```
+
+---
+
+## 5. 完整 JSON 示例
+
+```json
+[
+  {
+    "key": "same_author",
+    "title": "同作者作品",
+    "url": "https://example.com/search?keyword={{book.author}}&type=author&page=1"
+  },
+  {
+    "key": "readers_also_read",
+    "title": "读这本书的人还在读",
+    "url": "https://example.com/api/related?book={{book.bookUrl}}&limit=20"
+  },
+  {
+    "key": "same_genre",
+    "title": "同类推荐",
+    "url": "https://example.com/category/{{book.kind}}?page=1"
+  }
+]
+```
+
+以上配置会在详情页底部显示三行轮播:
+
+```
+┌─────────────────────────────────────────────┐
+│ [操作按钮区域]                                  │
+├─────────────────────────────────────────────┤
+│ 同作者作品                                      │
+│ [封面1] [封面2] [封面3] [封面4] →               │
+│                                             │
+│ 读这本书的人还在读                                │
+│ [封面1] [封面2] [封面3] [封面4] →               │
+│                                             │
+│ 同类推荐                                       │
+│ [封面1] [封面2] [封面3] [封面4] →               │
+├─────────────────────────────────────────────┤
+│ [书籍简介区域]                                  │
+└─────────────────────────────────────────────┘
+```
+
+---
+
+## 6. 简化示例
+
+如果只需要一行关联书籍,JSON 数组只包含一个元素即可:
+
+```json
+[
+  {
+    "title": "同作者作品",
+    "url": "https://example.com/search?keyword={{book.author}}"
+  }
+]
+```
+
+`key` 字段为可选,未提供时自动使用 `title` 作为标识。
+
+---
+
+## 7. URL 示例
+
+### 按作者查找相关书籍
+
+```
+https://example.com/search?keyword={{book.author}}&type=author
+```
+
+### 按书籍名称查找同系列
+
+```
+https://example.com/search?keyword={{book.name}}&type=related
+```
+
+### 按分类查找同类书籍
+
+```
+https://example.com/category/{{book.kind}}?page=1
+```
+
+### 使用 JS 表达式
+
+与 `exploreUrl` 一样,URL 完整支持 `@js:` 和 `<js>` 表达式。在 JS 上下文中,`book` 对象即当前书籍,可访问 `book.name`、`book.author`、`book.kind`、`book.bookUrl`、`book.tocUrl` 等属性。
+
+**简单拼接:**
+
+```
+@js:"https://example.com/api/related?author=" + java.net.URLEncoder.encode(book.author, "UTF-8") + "&book_id=" + book.bookUrl.split("/").pop()
+```
+
+**带条件逻辑:**
+
+```
+@js:
+var base = "https://example.com/api/related";
+if (book.kind && book.kind.contains("玄幻")) {
+  base + "?genre=fantasy&author=" + java.net.URLEncoder.encode(book.author, "UTF-8")
+} else {
+  base + "?author=" + java.net.URLEncoder.encode(book.author, "UTF-8")
+}
+```
+
+**带 Cookie/鉴权的请求:**
+
+```
+@js:
+var ck = "sessionid=" + (String(cookie.getKey("example.com", "sessionid")) || "");
+"https://example.com/api/related?book_id=" + book.bookUrl.split("/").pop() + "&cookie=" + java.net.URLEncoder.encode(ck, "UTF-8")
+```
+
+---
+
+## 8. 显示逻辑
+
+| 条件                                  | 行为                        |
+|:------------------------------------|:-------------------------|
+| `relatedBooks` 为空或未配置       | 不显示关联书籍模块               |
+| JSON 格式错误                          | 静默跳过,不影响详情页其他内容        |
+| 某个模块的 URL 请求失败                    | 跳过该模块,其他模块正常显示         |
+| 某个模块返回空列表                         | 跳过该模块,其他模块正常显示         |
+| 所有模块均无结果                          | 不显示关联书籍区域               |
+| 结果中包含当前书籍                         | 自动过滤掉当前书籍               |
+| 本地书籍(无书源)                         | 不显示关联书籍模块               |
+| 切换书源时                              | 清空关联书籍,重新加载新源的数据       |
+
+---
+
+## 9. 书源编辑器配置
+
+在书源编辑器的 **详情页** 选项卡中:
+
+| 字段                  | 值                                                                 |
+|:--------------------|:------------------------------------------------------------------|
+| relatedBooks | `[{"title":"同作者作品","url":"https://example.com/search?keyword={{book.author}}"}]` |
+
+---
+
+## 10. 最佳实践
+
+1. **合理设置模块数量**:建议 1-3 个模块,过多的轮播行会影响页面体验。
+2. **使用有意义的标题**:标题应清晰描述推荐来源,如「同作者作品」比「推荐」更具引导性。
+3. **优先使用作者或分类**:按作者查找是最常见的关联方式,能有效推荐同作者的其他作品。
+4. **避免过于宽泛的查询**:如果 URL 返回的结果与当前书籍关联性不强,用户体验会下降。
+5. **确保发现规则兼容**:URL 返回的数据必须能被 `ruleExplore` 正确解析。
+6. **简单场景用模板,复杂场景用 JS**:简单的 `{{book.author}}` 替换直接用模板语法;需要条件判断、字符串拼接、Cookie 处理等复杂逻辑时用 `@js:` 表达式。
+7. **测试边界情况**:测试作者名包含特殊字符(如 `&`、`#`、中文)时 URL 是否正常工作。模板变量会自动进行 URL 编码,但 `@js:` 表达式中需要手动调用 `java.net.URLEncoder.encode()`。
+8. **控制返回数量**:建议服务端限制返回数量(如 10-20 本),过多的轮播内容会影响体验。

+ 3 - 0
app/src/main/java/io/legado/app/data/entities/TranslationCache.kt

@@ -1,9 +1,12 @@
 package io.legado.app.data.entities
 
+import androidx.annotation.Keep
+
 /**
  * Chunk-level translation cache record stored in .chunks.jsonl files.
  * File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key.
  */
+@Keep
 data class TranslationCache(
     val chunkIndex: Int,
     val originalChunkContent: String,

+ 2 - 1
app/src/main/java/io/legado/app/data/entities/rule/BookInfoRule.kt

@@ -21,7 +21,8 @@ data class BookInfoRule(
     var tocUrl: String? = null,
     var wordCount: String? = null,
     var canReName: String? = null,
-    var downloadUrls: String? = null
+    var downloadUrls: String? = null,
+    var relatedBooks: String? = null
 ) : Parcelable {
 
     companion object {

+ 2 - 1
app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt

@@ -35,7 +35,8 @@ class DictionaryRepositoryImpl : DictionaryGateway {
 
     override fun updateBookDic(book: Book, newPairs: List<DictPair>) {
         val existingDict = getBookDictionaries(book)
-        val updatedPairs = existingDict.pairs.toMutableList()
+        @Suppress("SENSELESS_COMPARISON")
+        val updatedPairs = (existingDict.pairs ?: emptyList()).toMutableList()
 
         for (newPair in newPairs) {
             val existingIndex = updatedPairs.indexOfFirst {

+ 9 - 2
app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.repository
 
+import androidx.annotation.Keep
 import io.legado.app.domain.gateway.LlmGateway
 import io.legado.app.domain.model.DictPair
 import io.legado.app.domain.model.RetryReason
@@ -109,7 +110,7 @@ class LlmTranslateRepositoryImpl : LlmGateway {
         return if (response.isSuccessful()) {
             try {
                 val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
-                val translatedText = json.sentences.mapNotNull { it.trans }.joinToString("")
+                val translatedText = json?.sentences?.mapNotNull { it.trans }?.joinToString("") ?: ""
                 if (translatedText.isNotEmpty()) {
                     Result.success(translatedText)
                 } else {
@@ -166,7 +167,7 @@ class LlmTranslateRepositoryImpl : LlmGateway {
         return if (response.isSuccessful()) {
             try {
                 val json = GSON.fromJson(response.body, OpenAIResponse::class.java)
-                val rawContent = json.choices.firstOrNull()?.message?.content
+                val rawContent = json?.choices?.firstOrNull()?.message?.content
                 if (rawContent != null) {
                     // Parse the output with [dictionary] and [result] sections
                     val parseResult = parseLlmOutput(rawContent, dictionaries)
@@ -349,30 +350,36 @@ $terms
     }
 }
 
+@Keep
 data class GoogleTranslateResponse(
     val sentences: List<GoogleSentence>,
     val src: String?,
     val spell: GoogleSpell?
 )
 
+@Keep
 data class GoogleSentence(
     val trans: String?,
     val orig: String?,
     val backend: Int?
 )
 
+@Keep
 data class GoogleSpell(
     val spell: String?
 )
 
+@Keep
 data class OpenAIResponse(
     val choices: List<OpenAIChoice>
 )
 
+@Keep
 data class OpenAIChoice(
     val message: OpenAIMessage
 )
 
+@Keep
 data class OpenAIMessage(
     val content: String
 )

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

@@ -52,6 +52,7 @@ import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
+import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
@@ -163,6 +164,7 @@ val appModule = module {
     singleOf(::ResolveBookShelfStateUseCase)
     singleOf(::RefreshTocUseCase)
     singleOf(::AddBookUseCase)
+    singleOf(::AddToBookshelfUseCase)
     singleOf(::ImportBookshelfUseCase)
     singleOf(::ExportBookshelfUseCase)
     factory { GetReadRecordOverviewUseCase() }

+ 2 - 1
app/src/main/java/io/legado/app/domain/model/ContentChunker.kt

@@ -7,7 +7,8 @@ object ContentChunker {
     fun chunk(text: String, maxCharsPerChunk: Int = 3000): List<TextChunk> {
         if (text.isBlank()) return emptyList()
 
-        val paragraphs = text.split("\n\n").filter { it.isNotBlank() }
+        val normalizedText = text.replace("\r\n", "\n")
+        val paragraphs = normalizedText.split("\n\n").filter { it.isNotBlank() }
         if (paragraphs.isEmpty()) return emptyList()
 
         val chunks = mutableListOf<TextChunk>()

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

@@ -1,9 +1,11 @@
 package io.legado.app.domain.model
 
+import androidx.annotation.Keep
 import androidx.compose.runtime.Immutable
 
 /** 供 Gateway 和 ViewModel 使用的不可变模块模型 */
 @Immutable
+@Keep
 data class ModuleItem(
     val id: String = "",
     val sourceUrl: String = "",
@@ -26,6 +28,7 @@ data class ModuleItem(
 }
 
 @Immutable
+@Keep
 data class CustomSetItem(
     val id: String = "",
     val name: String = "",
@@ -33,6 +36,7 @@ data class CustomSetItem(
 )
 
 /** 模块定义(来自书源 JSON 解析或用户手动添加) */
+@Keep
 data class ModuleDef(
     val key: String = "",
     val type: String = "",

+ 5 - 0
app/src/main/java/io/legado/app/domain/model/TranslationModels.kt

@@ -1,9 +1,12 @@
 package io.legado.app.domain.model
 
+import androidx.annotation.Keep
+
 /**
  * A pair of original text and its translation, used for maintaining
  * consistent terminology across multiple translation chunks.
  */
+@Keep
 data class DictPair(
     val original: String,
     val translation: String
@@ -12,6 +15,7 @@ data class DictPair(
 /**
  * Collection of dictionary pairs with metadata.
  */
+@Keep
 data class BookDictionary(
     val bookUrl: String,
     val pairs: List<DictPair> = emptyList(),
@@ -21,6 +25,7 @@ data class BookDictionary(
 /**
  * A chunk of text for translation, with its index and paragraph mapping.
  */
+@Keep
 data class TextChunk(
     val index: Int,
     val content: String,

+ 20 - 0
app/src/main/java/io/legado/app/domain/usecase/AddToBookshelfUseCase.kt

@@ -0,0 +1,20 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.constant.BookType
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.help.book.removeType
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class AddToBookshelfUseCase {
+
+    suspend fun execute(book: SearchBook) = withContext(Dispatchers.IO) {
+        val b = book.toBook()
+        b.removeType(BookType.notShelf)
+        if (b.order == 0) {
+            b.order = appDb.bookDao.minOrder - 1
+        }
+        b.save()
+    }
+}

+ 107 - 94
app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt

@@ -13,6 +13,7 @@ import io.legado.app.domain.model.RetryReason
 import io.legado.app.domain.model.TextChunk
 import io.legado.app.help.book.BookHelp
 import io.legado.app.ui.config.translation.TranslationConfig
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
@@ -45,122 +46,134 @@ class TranslateChapterUseCase(
         onProgress: (TranslationProgress) -> Unit,
         onTranslateStarted: () -> Unit
     ): Result<String> = withContext(Dispatchers.IO) {
-        val originalContent = BookHelp.getContent(book, bookChapter)
-            ?: return@withContext Result.failure(Exception("Failed to read original content"))
+        try {
+            val originalContent = BookHelp.getContent(book, bookChapter)
+                ?: return@withContext Result.failure(Exception("Failed to read original content"))
 
-        val cachedTranslation =
-            translationCacheGateway.readTranslation(book, bookChapter, targetLanguage)
-        if (cachedTranslation != null) {
-            onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet()))
-            return@withContext Result.success(cachedTranslation)
-        }
+            val cachedTranslation =
+                translationCacheGateway.readTranslation(book, bookChapter, targetLanguage)
+            if (cachedTranslation != null) {
+                onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet()))
+                return@withContext Result.success(cachedTranslation)
+            }
 
-        val contentHash = translationCacheGateway.computeContentHash(originalContent)
+            val contentHash = translationCacheGateway.computeContentHash(originalContent)
 
-        // Load book dictionary for consistent terminology
-        val bookDictionary = dictionaryGateway.getBookDictionaries(book)
-        val dictionaries = bookDictionary.pairs.toMutableList()
+            // Load book dictionary for consistent terminology
+            val bookDictionary = dictionaryGateway.getBookDictionaries(book)
+            @Suppress("SENSELESS_COMPARISON")
+            val dictionaries = (bookDictionary.pairs ?: emptyList()).toMutableList()
 
-        // Callback to update dictionary pairs immediately (persist as soon as discovered)
-        val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
-            val merged = synchronized(dictionaryLock) {
-                mergeDictionaryPairs(dictionaries, newPairs)
-            }
-            if (merged) {
-                synchronized(dictionaryLock) {
-                    dictionaryGateway.updateBookDic(book, dictionaries.toList())
+            // Callback to update dictionary pairs immediately (persist as soon as discovered)
+            val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
+                val merged = synchronized(dictionaryLock) {
+                    mergeDictionaryPairs(dictionaries, newPairs)
+                }
+                if (merged) {
+                    synchronized(dictionaryLock) {
+                        dictionaryGateway.updateBookDic(book, dictionaries.toList())
+                    }
                 }
             }
-        }
 
-        val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk)
-        if (chunks.isEmpty()) {
-            return@withContext Result.failure(Exception("Failed to chunk content"))
-        }
+            val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk)
+            if (chunks.isEmpty()) {
+                return@withContext Result.failure(Exception("Failed to chunk content"))
+            }
 
-        val cachedChunks =
-            translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash)
-        val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex }
+            val cachedChunks =
+                translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash)
+            val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex }
 
-        val translatedChunks = mutableMapOf<Int, String>()
-        val pendingChunks = mutableListOf<TextChunk>()
+            val translatedChunks = mutableMapOf<Int, String>()
+            val pendingChunks = mutableListOf<TextChunk>()
 
-        // Load already cached chunks
-        for (chunk in chunks) {
-            val cached = cachedChunkMap[chunk.index]
-            if (cached != null && cached.translatedChunkContent != null) {
-                translatedChunks[chunk.index] = cached.translatedChunkContent
-            } else {
-                pendingChunks.add(chunk)
+            // Load already cached chunks
+            for (chunk in chunks) {
+                val cached = cachedChunkMap[chunk.index]
+                if (cached != null && cached.translatedChunkContent != null) {
+                    translatedChunks[chunk.index] = cached.translatedChunkContent
+                } else {
+                    pendingChunks.add(chunk)
+                }
             }
-        }
 
-        // If we have partial cached chunks, report initial mixed content
-        if (translatedChunks.isNotEmpty()) {
-            val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
-            onProgress(TranslationProgress(
-                translatedChunks.size,
-                chunks.size,
-                mixedContent,
-                translatedChunks.keys
-            ))
-        }
+            // If we have partial cached chunks, report initial mixed content
+            if (translatedChunks.isNotEmpty()) {
+                val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
+                onProgress(TranslationProgress(
+                    translatedChunks.size,
+                    chunks.size,
+                    mixedContent,
+                    translatedChunks.keys
+                ))
+            }
 
-        if (pendingChunks.isEmpty()) {
-            val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } }
-            val mergedContent = ContentChunker.merge(sortedChunks)
-            translationCacheGateway.writeTranslation(
-                book,
-                bookChapter,
-                targetLanguage,
-                mergedContent
-            )
-            onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
-            return@withContext Result.success(mergedContent)
-        }
+            if (pendingChunks.isEmpty()) {
+                val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } }
+                val mergedContent = ContentChunker.merge(sortedChunks)
+                translationCacheGateway.writeTranslation(
+                    book,
+                    bookChapter,
+                    targetLanguage,
+                    mergedContent
+                )
+                onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
+                return@withContext Result.success(mergedContent)
+            }
 
-        onTranslateStarted()
-        coroutineScope {
-            val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4)
-            val chunkGroups = pendingChunks.chunked(concurrentChunks)
+            onTranslateStarted()
+            var translationError: Throwable? = null
+            coroutineScope {
+                val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4)
+                val chunkGroups = pendingChunks.chunked(concurrentChunks)
 
-            for ((groupIndex, group) in chunkGroups.withIndex()) {
-                val results = group.map { chunk ->
-                    async {
-                        translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate)
-                    }
-                }.awaitAll()
+                for ((groupIndex, group) in chunkGroups.withIndex()) {
+                    val results = group.map { chunk ->
+                        async {
+                            translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate)
+                        }
+                    }.awaitAll()
 
-                for ((chunk, result) in group.zip(results)) {
-                    if (result.isSuccess) {
-                        translatedChunks[chunk.index] = result.getOrThrow()
-                        val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
-                        onProgress(TranslationProgress(
-                            translatedChunks.size + cachedChunkMap.size,
-                            chunks.size,
-                            mixedContent,
-                            translatedChunks.keys
-                        ))
-                    } else {
-                        val error = result.exceptionOrNull() ?: Exception("Translation failed")
-                        return@coroutineScope
+                    for ((chunk, result) in group.zip(results)) {
+                        if (result.isSuccess) {
+                            translatedChunks[chunk.index] = result.getOrThrow()
+                            val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
+                            onProgress(TranslationProgress(
+                                translatedChunks.size + cachedChunkMap.size,
+                                chunks.size,
+                                mixedContent,
+                                translatedChunks.keys
+                            ))
+                        } else {
+                            translationError = result.exceptionOrNull() ?: Exception("Translation failed")
+                            return@coroutineScope
+                        }
                     }
                 }
             }
-        }
 
-        if (translatedChunks.size != chunks.size) {
-            return@withContext Result.failure(Exception("Translation incomplete"))
-        }
+            translationError?.let {
+                return@withContext Result.failure(it)
+            }
 
-        val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk ->
-            translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) }
-        }
-        val mergedContent = ContentChunker.merge(allTranslatedChunks)
-        translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent)
+            if (translatedChunks.size != chunks.size) {
+                return@withContext Result.failure(Exception("Translation incomplete"))
+            }
+
+            val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk ->
+                translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) }
+            }
+            val mergedContent = ContentChunker.merge(allTranslatedChunks)
+            translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent)
 
-        onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
-        Result.success(mergedContent)
+            onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
+            Result.success(mergedContent)
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
     }
 
     /**

+ 0 - 6
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -938,12 +938,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefBoolean(PreferKey.shouldShowExpandButton, value)
         }
 
-    var exploreFilterState: Int
-        get() = appCtx.getPrefInt(PreferKey.exploreFilterState, 0)
-        set(value) {
-            appCtx.putPrefInt(PreferKey.exploreFilterState, value)
-        }
-
     var exploreLayoutState: Int
         get() = appCtx.getPrefInt(PreferKey.exploreLayoutState, 0)
         set(value) {

+ 173 - 2
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -3,10 +3,13 @@ package io.legado.app.help.config
 import android.content.Context
 import android.net.Uri
 import com.google.gson.GsonBuilder
+import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.GSON
 import splitties.init.appCtx
 import java.io.File
+import java.io.FileOutputStream
 
 /**
  * 轻量级主题导入导出系统
@@ -184,6 +187,7 @@ object ThemeImportExport {
             defaultHomePage = ThemeConfig.defaultHomePage,
 
             // 导航栏图标
+            navIconHome = ThemeConfig.navIconHome,
             navIconBookshelf = ThemeConfig.navIconBookshelf,
             navIconExplore = ThemeConfig.navIconExplore,
             navIconRss = ThemeConfig.navIconRss,
@@ -194,11 +198,70 @@ object ThemeImportExport {
 
             // 其他
             useFlexibleTopAppBar = ThemeConfig.useFlexibleTopAppBar,
+            bgImageLight = ThemeConfig.bgImageLight,
+            bgImageDark = ThemeConfig.bgImageDark,
             bgImageBlurring = ThemeConfig.bgImageBlurring,
-            bgImageNBlurring = ThemeConfig.bgImageNBlurring
+            bgImageNBlurring = ThemeConfig.bgImageNBlurring,
+            appFontPath = ThemeConfig.appFontPath,
+
+            // 封面配置 (CoverConfig)
+            coverLoadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
+            coverUseDefault = CoverConfig.useDefaultCover,
+            coverShowShadow = CoverConfig.coverShowShadow,
+            coverShowStroke = CoverConfig.coverShowStroke,
+            coverDefaultColor = CoverConfig.coverDefaultColor,
+            coverDefaultImage = CoverConfig.defaultCover,
+            coverTextColor = CoverConfig.coverTextColor,
+            coverShadowColor = CoverConfig.coverShadowColor,
+            coverShowName = CoverConfig.coverShowName,
+            coverShowAuthor = CoverConfig.coverShowAuthor,
+            coverDefaultImageDark = CoverConfig.defaultCoverDark,
+            coverTextColorN = CoverConfig.coverTextColorN,
+            coverShadowColorN = CoverConfig.coverShadowColorN,
+            coverShowNameN = CoverConfig.coverShowNameN,
+            coverShowAuthorN = CoverConfig.coverShowAuthorN,
+            coverInfoOrientation = CoverConfig.coverInfoOrientation,
+            assets = exportAssets()
         )
     }
 
+    /**
+     * 将相关的图片、字体资源转换为Base64
+     */
+    private fun exportAssets(): Map<String, String> {
+        val assets = mutableMapOf<String, String>()
+        val assetPaths = mapOf(
+            "bgImageLight" to ThemeConfig.bgImageLight,
+            "bgImageDark" to ThemeConfig.bgImageDark,
+            "navIconHome" to ThemeConfig.navIconHome,
+            "navIconBookshelf" to ThemeConfig.navIconBookshelf,
+            "navIconExplore" to ThemeConfig.navIconExplore,
+            "navIconRss" to ThemeConfig.navIconRss,
+            "navIconMy" to ThemeConfig.navIconMy,
+            "appFontPath" to ThemeConfig.appFontPath,
+            "coverDefaultImage" to CoverConfig.defaultCover,
+            "coverDefaultImageDark" to CoverConfig.defaultCoverDark
+        )
+
+        assetPaths.forEach { (key, path) ->
+            if (!path.isNullOrBlank()) {
+                try {
+                    val file = if (path.startsWith("content://")) {
+                        null // TODO: 处理 content uri
+                    } else {
+                        File(path)
+                    }
+                    if (file?.exists() == true) {
+                        assets[key] = EncoderUtils.base64Encode(file.readBytes())
+                    }
+                } catch (e: Exception) {
+                    e.printStackTrace()
+                }
+            }
+        }
+        return assets
+    }
+
     /**
      * 将导出数据应用到当前配置
      */
@@ -268,6 +331,7 @@ object ThemeImportExport {
         ThemeConfig.defaultHomePage = data.defaultHomePage
 
         // 导航栏图标
+        ThemeConfig.navIconHome = data.navIconHome
         ThemeConfig.navIconBookshelf = data.navIconBookshelf
         ThemeConfig.navIconExplore = data.navIconExplore
         ThemeConfig.navIconRss = data.navIconRss
@@ -278,8 +342,90 @@ object ThemeImportExport {
 
         // 其他
         ThemeConfig.useFlexibleTopAppBar = data.useFlexibleTopAppBar
+        ThemeConfig.bgImageLight = data.bgImageLight
+        ThemeConfig.bgImageDark = data.bgImageDark
         ThemeConfig.bgImageBlurring = data.bgImageBlurring
         ThemeConfig.bgImageNBlurring = data.bgImageNBlurring
+        ThemeConfig.appFontPath = data.appFontPath
+
+        // 封面配置 (CoverConfig)
+        CoverConfig.loadCoverOnlyWifi = data.coverLoadOnlyWifi
+        CoverConfig.useDefaultCover = data.coverUseDefault
+        CoverConfig.coverShowShadow = data.coverShowShadow
+        CoverConfig.coverShowStroke = data.coverShowStroke
+        CoverConfig.coverDefaultColor = data.coverDefaultColor
+        CoverConfig.defaultCover = data.coverDefaultImage
+        CoverConfig.coverTextColor = data.coverTextColor
+        CoverConfig.coverShadowColor = data.coverShadowColor
+        CoverConfig.coverShowName = data.coverShowName
+        CoverConfig.coverShowAuthor = data.coverShowAuthor
+        CoverConfig.defaultCoverDark = data.coverDefaultImageDark
+        CoverConfig.coverTextColorN = data.coverTextColorN
+        CoverConfig.coverShadowColorN = data.coverShadowColorN
+        CoverConfig.coverShowNameN = data.coverShowNameN
+        CoverConfig.coverShowAuthorN = data.coverShowAuthorN
+        CoverConfig.coverInfoOrientation = data.coverInfoOrientation
+
+        // 应用嵌入的资源
+        data.assets?.let { assets ->
+            applyAssets(assets)
+        }
+    }
+
+    private fun applyAssets(assets: Map<String, String>) {
+        assets.forEach { (key, base64) ->
+            try {
+                val bytes = EncoderUtils.base64DecodeToByteArray(base64)
+                val destFile = when {
+                    key.startsWith("bgImage") -> {
+                        val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
+                        val folder = File(baseDir, key)
+                        folder.mkdirs()
+                        File(folder, "theme_asset_${System.currentTimeMillis()}.jpg")
+                    }
+
+                    key.startsWith("navIcon") -> {
+                        val folder = File(appCtx.filesDir, "nav_icons")
+                        folder.mkdirs()
+                        File(folder, "${key.removePrefix("navIcon").lowercase()}.png")
+                    }
+
+                    key == "appFontPath" -> {
+                        val folder = File(appCtx.filesDir, "fonts")
+                        folder.mkdirs()
+                        File(folder, "theme_font.ttf")
+                    }
+
+                    key.startsWith("coverDefaultImage") -> {
+                        val baseDir = appCtx.getExternalFilesDir(null) ?: appCtx.filesDir
+                        val folder = File(baseDir, "covers")
+                        folder.mkdirs()
+                        File(folder, "${key}_${System.currentTimeMillis()}.jpg")
+                    }
+
+                    else -> null
+                }
+
+                destFile?.let { file ->
+                    FileOutputStream(file).use { it.write(bytes) }
+                    val path = file.absolutePath
+                    when (key) {
+                        "bgImageLight" -> ThemeConfig.bgImageLight = path
+                        "bgImageDark" -> ThemeConfig.bgImageDark = path
+                        "navIconHome" -> ThemeConfig.navIconHome = path
+                        "navIconBookshelf" -> ThemeConfig.navIconBookshelf = path
+                        "navIconExplore" -> ThemeConfig.navIconExplore = path
+                        "navIconRss" -> ThemeConfig.navIconRss = path
+                        "navIconMy" -> ThemeConfig.navIconMy = path
+                        "appFontPath" -> ThemeConfig.appFontPath = path
+                        "coverDefaultImage" -> CoverConfig.defaultCover = path
+                        "coverDefaultImageDark" -> CoverConfig.defaultCoverDark = path
+                    }
+                }
+            } catch (e: Exception) {
+                e.printStackTrace()
+            }
+        }
     }
 
     /**
@@ -405,6 +551,7 @@ data class ThemeExportData(
     val defaultHomePage: String = "bookshelf",
 
     // 导航栏图标
+    val navIconHome: String = "",
     val navIconBookshelf: String = "",
     val navIconExplore: String = "",
     val navIconRss: String = "",
@@ -415,8 +562,32 @@ data class ThemeExportData(
 
     // 其他
     val useFlexibleTopAppBar: Boolean = true,
+    val bgImageLight: String? = null,
+    val bgImageDark: String? = null,
     val bgImageBlurring: Int = 0,
-    val bgImageNBlurring: Int = 0
+    val bgImageNBlurring: Int = 0,
+    val appFontPath: String? = null,
+
+    // 封面配置 (CoverConfig)
+    val coverLoadOnlyWifi: Boolean = false,
+    val coverUseDefault: Boolean = false,
+    val coverShowShadow: Boolean = false,
+    val coverShowStroke: Boolean = true,
+    val coverDefaultColor: Boolean = true,
+    val coverDefaultImage: String = "",
+    val coverTextColor: Int = -16777216,
+    val coverShadowColor: Int = -16777216,
+    val coverShowName: Boolean = true,
+    val coverShowAuthor: Boolean = true,
+    val coverDefaultImageDark: String = "",
+    val coverTextColorN: Int = -1,
+    val coverShadowColorN: Int = -1,
+    val coverShowNameN: Boolean = true,
+    val coverShowAuthorN: Boolean = true,
+    val coverInfoOrientation: String = "0",
+
+    // 嵌入的二进制资源 (Base64)
+    val assets: Map<String, String>? = null
 )
 
 /**

+ 81 - 4
app/src/main/java/io/legado/app/model/webBook/WebBook.kt

@@ -17,9 +17,11 @@ import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.RuleData
+import io.legado.app.model.analyzeRule.RuleDataInterface
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineStart
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.sync.Semaphore
 import kotlin.coroutines.CoroutineContext
@@ -106,15 +108,15 @@ object WebBook {
         bookSource: BookSource,
         url: String,
         page: Int? = 1,
+        ruleData: RuleDataInterface = RuleData(),
     ): ArrayList<SearchBook> {
-        val ruleData = RuleData()
         val analyzeUrl = AnalyzeUrl(
             mUrl = url,
             page = page,
             baseUrl = bookSource.bookSourceUrl,
             source = bookSource,
             ruleData = ruleData,
-            coroutineContext = coroutineContext
+            coroutineContext = currentCoroutineContext()
         )
         var res = analyzeUrl.getStrResponseAwait()
         //检测书源是否已登录
@@ -124,9 +126,14 @@ object WebBook {
             }
         }
         checkRedirect(bookSource, res)
+        val listRuleData = when (ruleData) {
+            is RuleData -> ruleData
+            is Book -> RuleData()
+            else -> RuleData()
+        }
         return BookList.analyzeBookList(
             bookSource = bookSource,
-            ruleData = ruleData,
+            ruleData = listRuleData,
             analyzeUrl = analyzeUrl,
             baseUrl = res.url,
             body = res.body,
@@ -138,8 +145,78 @@ object WebBook {
         bookSource: BookSource,
         url: String,
         page: Int? = 1,
+        ruleData: RuleDataInterface = RuleData(),
     ): List<SearchBook> {
-        return exploreBookAwait(bookSource, url, page)
+        return exploreBookAwait(bookSource, url, page, ruleData)
+    }
+
+    suspend fun exploreBookWithResolvedUrl(
+        bookSource: BookSource,
+        url: String,
+        page: Int? = 1,
+        ruleData: RuleDataInterface = RuleData(),
+    ): Pair<String, ArrayList<SearchBook>> {
+        val analyzeUrl = AnalyzeUrl(
+            mUrl = url,
+            page = page,
+            baseUrl = bookSource.bookSourceUrl,
+            source = bookSource,
+            ruleData = ruleData,
+            coroutineContext = currentCoroutineContext()
+        )
+        var res = analyzeUrl.getStrResponseAwait()
+        bookSource.loginCheckJs?.let { checkJs ->
+            if (checkJs.isNotBlank()) {
+                res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
+            }
+        }
+        checkRedirect(bookSource, res)
+        val resolvedUrl = res.url
+        val listRuleData = when (ruleData) {
+            is RuleData -> ruleData
+            is Book -> RuleData()
+            else -> RuleData()
+        }
+        val books = BookList.analyzeBookList(
+            bookSource = bookSource,
+            ruleData = listRuleData,
+            analyzeUrl = analyzeUrl,
+            baseUrl = res.url,
+            body = res.body,
+            isSearch = false
+        )
+        return resolvedUrl to books
+    }
+
+    suspend fun exploreBookWithRuleData(
+        bookSource: BookSource,
+        url: String,
+        page: Int? = 1,
+    ): ArrayList<SearchBook> {
+        val ruleData = RuleData()
+        val analyzeUrl = AnalyzeUrl(
+            mUrl = url,
+            page = page,
+            baseUrl = bookSource.bookSourceUrl,
+            source = bookSource,
+            ruleData = ruleData,
+            coroutineContext = currentCoroutineContext()
+        )
+        var res = analyzeUrl.getStrResponseAwait()
+        bookSource.loginCheckJs?.let { checkJs ->
+            if (checkJs.isNotBlank()) {
+                res = analyzeUrl.evalJS(checkJs, result = res) as StrResponse
+            }
+        }
+        checkRedirect(bookSource, res)
+        return BookList.analyzeBookList(
+            bookSource = bookSource,
+            ruleData = ruleData,
+            analyzeUrl = analyzeUrl,
+            baseUrl = res.url,
+            body = res.body,
+            isSearch = false
+        )
     }
 
     /**

+ 45 - 67
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -29,13 +29,10 @@ import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material.icons.filled.FilterList
 import androidx.compose.material.icons.filled.GridView
 import androidx.compose.material.icons.outlined.FilterAlt
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.Icon
 import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Slider
 import androidx.compose.runtime.Composable
@@ -56,6 +53,7 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
+import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
@@ -66,10 +64,9 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.book.SearchBookGridItem
 import io.legado.app.ui.widget.components.book.SearchBookListItem
+import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.explore.ExploreKindSelectSheet
-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.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
@@ -95,17 +92,28 @@ fun ExploreShowScreen(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
+    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
+    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
 
     LaunchedEffect(sourceUrl, exploreUrl, viewModel) {
         viewModel.initData(sourceUrl, exploreUrl)
     }
 
-    val books by viewModel.uiBooks.collectAsState()
+    val rawBooks by viewModel.uiBooks.collectAsState()
+    val filterStateId = CoverConfig.exploreFilterState
+    val books = remember(rawBooks, filterStateId) {
+        val filter = BookFilterState.fromId(filterStateId)
+        when (filter) {
+            BookFilterState.SHOW_ALL -> rawBooks
+            BookFilterState.HIDE_IN_SHELF -> rawBooks.filter { it.shelfState != BookShelfState.IN_SHELF }
+            BookFilterState.HIDE_SAME_NAME_AUTHOR -> rawBooks.filter { it.shelfState != BookShelfState.SAME_NAME_AUTHOR }
+            BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> rawBooks.filter { it.shelfState == BookShelfState.NOT_IN_SHELF }
+        }
+    }
     val isBookEnd by viewModel.isEnd.collectAsState()
     val shouldTriggerAutoLoad by viewModel.shouldTriggerAutoLoad.collectAsState()
     val isLoading by viewModel.isLoading.collectAsState()
     val errorMsg by viewModel.errorMsg.collectAsState()
-    val filterState by viewModel.filterState.collectAsState()
     val selectedTitle by viewModel.selectedKindTitle.collectAsState()
     val listState = rememberLazyListState()
     val gridState = rememberLazyGridState()
@@ -140,7 +148,6 @@ fun ExploreShowScreen(
             total > 0 && last >= total - 1
         }
     }
-    var showMenu by remember { mutableStateOf(false) }
 
     LaunchedEffect(shouldLoadMoreList.value, isGridMode) {
         if (!isGridMode && shouldLoadMoreList.value) viewModel.loadMore()
@@ -250,12 +257,6 @@ fun ExploreShowScreen(
                         horizontalArrangement = Arrangement.End,
                         modifier = Modifier.animateContentSize(tween(300))
                     ) {
-                        TopBarActionButton(
-                            onClick = { showMenu = true },
-                            imageVector = Icons.Default.FilterList,
-                            contentDescription = "Filter"
-                        )
-
                         TopBarActionButton(
                             onClick = { showKindSheet = true },
                             imageVector = Icons.Outlined.FilterAlt,
@@ -280,59 +281,6 @@ fun ExploreShowScreen(
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
                         contentDescription = "切换布局"
                     )
-
-                    RoundDropdownMenu(
-                        expanded = showMenu,
-                        onDismissRequest = { showMenu = false }
-                    ) {
-                        RoundDropdownMenuItem(
-                            text = "全部显示",
-                            onClick = {
-                                viewModel.setFilterState(BookFilterState.SHOW_ALL)
-                                showMenu = false
-                            },
-                            trailingIcon = {
-                                if (filterState == BookFilterState.SHOW_ALL)
-                                    Icon(Icons.Default.Check, null)
-                            }
-                        )
-
-                        RoundDropdownMenuItem(
-                            text = "隐藏已在书架的同源书籍",
-                            onClick = {
-                                viewModel.setFilterState(BookFilterState.HIDE_IN_SHELF)
-                                showMenu = false
-                            },
-                            trailingIcon = {
-                                if (filterState == BookFilterState.HIDE_IN_SHELF)
-                                    Icon(Icons.Default.Check, null)
-                            }
-                        )
-
-                        RoundDropdownMenuItem(
-                            text = "隐藏已在书架的非同源书籍",
-                            onClick = {
-                                viewModel.setFilterState(BookFilterState.HIDE_SAME_NAME_AUTHOR)
-                                showMenu = false
-                            },
-                            trailingIcon = {
-                                if (filterState == BookFilterState.HIDE_SAME_NAME_AUTHOR)
-                                    Icon(Icons.Default.Check, null)
-                            }
-                        )
-
-                        RoundDropdownMenuItem(
-                            text = "只显示不在书架的书籍",
-                            onClick = {
-                                viewModel.setFilterState(BookFilterState.SHOW_NOT_IN_SHELF_ONLY)
-                                showMenu = false
-                            },
-                            trailingIcon = {
-                                if (filterState == BookFilterState.SHOW_NOT_IN_SHELF_ONLY)
-                                    Icon(Icons.Default.Check, null)
-                            }
-                        )
-                    }
                 },
                 scrollBehavior = scrollBehavior
             )
@@ -377,6 +325,10 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = { onBookClick(item.book, sharedCoverKey) },
+                                onLongClick = { book, coverKey ->
+                                    previewBook = book
+                                    previewSharedCoverKey = coverKey
+                                },
                                 modifier = Modifier.animateItem(),
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
@@ -416,6 +368,10 @@ fun ExploreShowScreen(
                                 book = item.book,
                                 shelfState = item.shelfState,
                                 onClick = { onBookClick(item.book, sharedCoverKey) },
+                                onLongClick = { book, coverKey ->
+                                    previewBook = book
+                                    previewSharedCoverKey = coverKey
+                                },
                                 modifier = Modifier.animateItem(),
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
@@ -437,6 +393,24 @@ fun ExploreShowScreen(
             }
         }
     }
+
+    val previewShelfState = previewBook?.let { book ->
+        books.find { it.book.bookUrl == book.bookUrl }?.shelfState
+            ?: BookShelfState.NOT_IN_SHELF
+    }
+    SearchBookPreviewSheet(
+        data = previewBook,
+        shelfState = previewShelfState,
+        sharedCoverKey = previewSharedCoverKey,
+        onDismissRequest = { previewBook = null },
+        onOpenDetail = { book, sharedCoverKey ->
+            previewBook = null
+            onBookClick(book, sharedCoverKey)
+        },
+        onAddToShelf = { book ->
+            viewModel.onAddToShelf(book)
+        },
+    )
 }
 
 @OptIn(ExperimentalSharedTransitionApi::class)
@@ -445,6 +419,7 @@ fun ExploreBookItem(
     book: SearchBook,
     shelfState: BookShelfState,
     onClick: () -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -454,6 +429,7 @@ fun ExploreBookItem(
         book = book,
         shelfState = shelfState,
         onClick = onClick,
+        onLongClick = onLongClick,
         modifier = modifier,
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,
@@ -467,6 +443,7 @@ fun ExploreBookGridItem(
     book: SearchBook,
     onClick: () -> Unit,
     shelfState: BookShelfState,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -476,6 +453,7 @@ fun ExploreBookGridItem(
         book = book,
         shelfState = shelfState,
         onClick = onClick,
+        onLongClick = onLongClick,
         modifier = modifier,
         sharedTransitionScope = sharedTransitionScope,
         animatedVisibilityScope = animatedVisibilityScope,

+ 7 - 28
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowViewModel.kt

@@ -7,6 +7,7 @@ import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
@@ -56,10 +57,10 @@ class ExploreShowViewModel(
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
+    private val addToBookshelfUseCase: AddToBookshelfUseCase,
 ) : ViewModel() {
 
     private val _rawBooks = MutableStateFlow<List<SearchBook>>(emptyList())
-    private val _filterState = MutableStateFlow(BookFilterState.fromId(AppConfig.exploreFilterState))
     private val _isLoading = MutableStateFlow(false)
     private val _isRefreshing = MutableStateFlow(false)
     private val _errorMsg = MutableStateFlow<String?>(null)
@@ -88,12 +89,9 @@ class ExploreShowViewModel(
 
     val uiBooks = combine(
         _rawBooks,
-        _filterState,
         _bookshelf
-    ) { books, filter, bookshelf ->
-        books.filter { item ->
-            isBookValid(item, filter, bookshelf)
-        }.map { item ->
+    ) { books, bookshelf ->
+        books.map { item ->
             ExploreBookItemUi(
                 book = item,
                 shelfState = resolveBookShelfStateUseCase.execute(
@@ -117,7 +115,6 @@ class ExploreShowViewModel(
 
     val isLoading = _isLoading.asStateFlow()
     val errorMsg = _errorMsg.asStateFlow()
-    val filterState = _filterState.asStateFlow()
 
     init {
         viewModelScope.launch {
@@ -173,11 +170,6 @@ class ExploreShowViewModel(
         loadMore(isRefresh = true)
     }
 
-    fun setFilterState(state: BookFilterState) {
-        _filterState.value = state
-        AppConfig.exploreFilterState = state.id
-    }
-
     fun setLayout() {
         val newState = if (_layoutState.value == 0) 1 else 0
         _layoutState.value = newState
@@ -240,22 +232,9 @@ class ExploreShowViewModel(
         )
     }
 
-    private fun isBookValid(
-        book: SearchBook,
-        filter: BookFilterState,
-        shelf: Set<BookShelfKey>
-    ): Boolean {
-        val state = resolveBookShelfStateUseCase.execute(
-            name = book.name,
-            author = book.author,
-            url = book.bookUrl,
-            shelf = shelf
-        )
-        return when (filter) {
-            BookFilterState.SHOW_ALL -> true
-            BookFilterState.HIDE_IN_SHELF -> state != BookShelfState.IN_SHELF
-            BookFilterState.HIDE_SAME_NAME_AUTHOR -> state != BookShelfState.SAME_NAME_AUTHOR
-            BookFilterState.SHOW_NOT_IN_SHELF_ONLY -> state == BookShelfState.NOT_IN_SHELF
+    fun onAddToShelf(book: SearchBook) {
+        viewModelScope.launch {
+            addToBookshelfUseCase.execute(book)
         }
     }
 }

+ 15 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt

@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
+import io.legado.app.utils.startActivity
 import org.koin.androidx.viewmodel.ext.android.viewModel
 
 class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
@@ -28,6 +29,20 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
             onOpenSearch = { keyword ->
                 startActivity(MainActivity.createSearchIntent(this, key = keyword))
             },
+            onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath ->
+                startActivity<BookInfoActivity> {
+                    putExtra("bookUrl", bookUrl)
+                    putExtra("name", name)
+                    putExtra("author", author)
+                    putExtra("origin", origin)
+                    putExtra("coverPath", coverPath)
+                }
+            },
+            onNavigateToExploreShow = { title, sourceUrl, exploreUrl ->
+                startActivity(
+                    MainActivity.createExploreShowIntent(this, title, sourceUrl, exploreUrl)
+                )
+            },
         )
     }
 

+ 28 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -4,8 +4,11 @@ import android.net.Uri
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
 
 data class BookInfoUiState(
     val book: Book? = null,
@@ -18,6 +21,7 @@ data class BookInfoUiState(
     val readRecordTimelineDays: List<ReadRecordTimelineDay> = emptyList(),
     val inBookshelf: Boolean = false,
     val bookSource: BookSource? = null,
+    val relatedBooks: ImmutableList<RelatedBooksUi> = persistentListOf(),
     val isTocLoading: Boolean = true,
     val isBusy: Boolean = false,
     val deleteAlertEnabled: Boolean = true,
@@ -58,6 +62,14 @@ data class BookInfoWebFile(
     override fun toString(): String = name
 }
 
+data class RelatedBooksUi(
+    val key: String,
+    val title: String,
+    val url: String,
+    val resolvedUrl: String,
+    val books: ImmutableList<SearchBook>,
+)
+
 sealed interface BookInfoIntent {
     data object DismissSheet : BookInfoIntent
     data object DismissDialog : BookInfoIntent
@@ -102,6 +114,9 @@ sealed interface BookInfoIntent {
         val entryName: String,
         val openAfterImport: Boolean,
     ) : BookInfoIntent
+
+    data class RelatedBookClick(val book: SearchBook) : BookInfoIntent
+    data class RelatedBooksMore(val title: String, val url: String) : BookInfoIntent
 }
 
 sealed interface BookInfoEffect {
@@ -133,6 +148,19 @@ sealed interface BookInfoEffect {
         val variable: String?,
         val comment: String,
     ) : BookInfoEffect
+
+    data class NavigateToBookInfo(
+        val name: String?,
+        val author: String?,
+        val bookUrl: String,
+        val origin: String?,
+        val coverPath: String?,
+    ) : BookInfoEffect
+    data class NavigateToExploreShow(
+        val title: String?,
+        val sourceUrl: String,
+        val exploreUrl: String?,
+    ) : BookInfoEffect
 }
 
 sealed interface BookInfoCallbackAction {

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -48,6 +48,8 @@ fun BookInfoRouteScreen(
     onBack: () -> Unit,
     onFinish: (resultCode: Int?, afterTransition: Boolean) -> Unit,
     onOpenSearch: (String) -> Unit,
+    onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> },
+    onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> },
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
@@ -165,6 +167,14 @@ fun BookInfoRouteScreen(
                         )
                     )
                 }
+
+                is BookInfoEffect.NavigateToBookInfo -> {
+                    onNavigateToBookInfo(effect.name, effect.author, effect.bookUrl, effect.origin, effect.coverPath)
+                }
+
+                is BookInfoEffect.NavigateToExploreShow -> {
+                    onNavigateToExploreShow(effect.title, effect.sourceUrl, effect.exploreUrl)
+                }
             }
         }
     }

+ 156 - 82
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -1,9 +1,9 @@
 package io.legado.app.ui.book.info
 
 import androidx.compose.animation.AnimatedVisibilityScope
+import androidx.compose.animation.Crossfade
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
 import androidx.compose.foundation.background
 import androidx.compose.foundation.clickable
@@ -26,6 +26,7 @@ import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
 import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.BookmarkAdd
@@ -51,7 +52,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
 import androidx.compose.ui.draw.blur
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
@@ -70,10 +70,12 @@ import io.legado.app.R
 import io.legado.app.constant.BookType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.BookCover
 import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.ui.main.homepage.modules.BannerModule
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.ProvideThemeOverride
@@ -87,6 +89,7 @@ import io.legado.app.ui.widget.components.AppPullToRefresh
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -104,6 +107,8 @@ import io.legado.app.ui.widget.components.topbar.M3GlassScrollBehavior
 import io.legado.app.ui.widget.components.topbar.MiuixGlassScrollBehavior
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.delay
 import org.koin.compose.koinInject
 import top.yukonga.miuix.kmp.basic.MiuixScrollBehavior
@@ -186,7 +191,12 @@ private fun BookInfoScreenContent(
             }
         } else {
             Box(modifier = Modifier.fillMaxSize()) {
-                BookInfoBackdrop(book)
+                BookInfoBackdrop(
+                    bookUrl = book.bookUrl,
+                    coverUrl = book.coverUrl,
+                    customCoverUrl = book.customCoverUrl,
+                    origin = book.origin,
+                )
                 AppPullToRefresh(
                     modifier = Modifier.fillMaxSize(),
                     isRefreshing = state.isTocLoading,
@@ -233,6 +243,18 @@ private fun BookInfoScreenContent(
                                     onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
                                     onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
                                 )
+                                state.relatedBooks.forEach { module ->
+                                    RelatedBooksBanner(
+                                        title = module.title,
+                                        books = module.books,
+                                        onBookClick = { book, _ ->
+                                            onIntent(BookInfoIntent.RelatedBookClick(book))
+                                        },
+                                        onMoreClick = {
+                                            onIntent(BookInfoIntent.RelatedBooksMore(module.title, module.resolvedUrl))
+                                        },
+                                    )
+                                }
                                 BookInfoSummary(
                                     book = book,
                                     chapterList = state.chapterList,
@@ -401,23 +423,8 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
     if (useDefaultCover) return null
 
     val imageLoader = koinInject<ImageLoader>()
-    var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) }
-
-    LaunchedEffect(book?.bookUrl) {
-        shouldExtractColor = false
-        if (book != null) {
-            delay(520)
-            shouldExtractColor = true
-        }
-    }
-
-    val isNight = AppConfig.isNightTheme
-
-    val coverPath = if (shouldExtractColor) {
-        book?.getDisplayCover()
-    } else null
-
-    val sourceOrigin = if (shouldExtractColor) book?.origin else null
+    val coverPath = book?.getDisplayCover()
+    val sourceOrigin = book?.origin
     val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
     val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
         listOf(coverPath, sourceOrigin, loadOnlyWifi)
@@ -471,62 +478,60 @@ private fun BookInfoTopBarActions(
 }
 
 @Composable
-private fun BookInfoBackdrop(book: Book) {
-    val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover"
+private fun BookInfoBackdrop(
+    bookUrl: String,
+    coverUrl: String?,
+    customCoverUrl: String?,
+    origin: String,
+) {
+    val useDefaultCover = AppConfig.useDefaultCover || customCoverUrl == "use_default_cover"
     val isNight = AppConfig.isNightTheme
 
-    val cover = if (useDefaultCover) {
-        BookCover.getRandomDefaultPath(book.bookUrl, isNight)
-    } else {
-        book.getDisplayCover()
+    val cover = remember(bookUrl, customCoverUrl, coverUrl, isNight) {
+        if (useDefaultCover) {
+            BookCover.getRandomDefaultPath(bookUrl, isNight)
+        } else {
+            if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl
+        }
     }
-    val sourceOrigin = if (!useDefaultCover) book.origin else null
+    val sourceOrigin = if (!useDefaultCover) origin else null
     val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
     val context = LocalContext.current
     val imageLoader = koinInject<ImageLoader>()
-    var showBackdropImage by remember(cover) { mutableStateOf(false) }
-
-    LaunchedEffect(cover) {
-        showBackdropImage = false
-        if (!cover.isNullOrBlank()) {
-            delay(520)
-            showBackdropImage = true
-        }
-    }
 
-    val backdropAlpha by animateFloatAsState(
-        targetValue = if (showBackdropImage) 1f else 0f,
-        animationSpec = tween(800),
-        label = "BackdropFade"
-    )
-
-    val backdropRequest = remember(cover, sourceOrigin, loadOnlyWifi, context) {
-        buildCoverImageRequest(
-            context = context,
-            data = cover,
-            sourceOrigin = sourceOrigin,
-            loadOnlyWifi = loadOnlyWifi,
-            crossfade = false,
-        )
-    }
     val seedOverlay = lerp(
         LegadoTheme.colorScheme.secondaryContainer,
         LegadoTheme.seedColor,
         0.42f
     )
     Box(modifier = Modifier.fillMaxSize()) {
-        if (!cover.isNullOrBlank()) {
-            AsyncImage(
-                model = backdropRequest,
-                imageLoader = imageLoader,
-                contentDescription = null,
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .height(480.dp)
-                    .blur(24.dp)
-                    .alpha(backdropAlpha),
-                contentScale = ContentScale.Crop,
-            )
+        Crossfade(
+            targetState = cover,
+            animationSpec = tween(800),
+            label = "BackdropCrossfade"
+        ) { currentCover ->
+            if (!currentCover.isNullOrBlank()) {
+                val backdropRequest = remember(currentCover, sourceOrigin, loadOnlyWifi, context) {
+                    buildCoverImageRequest(
+                        context = context,
+                        data = currentCover,
+                        sourceOrigin = sourceOrigin,
+                        loadOnlyWifi = loadOnlyWifi,
+                        crossfade = true,
+                        memoryCacheKey = currentCover,
+                    )
+                }
+                AsyncImage(
+                    model = backdropRequest,
+                    imageLoader = imageLoader,
+                    contentDescription = null,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(480.dp)
+                        .blur(24.dp),
+                    contentScale = ContentScale.Crop,
+                )
+            }
         }
         Box(
             modifier = Modifier
@@ -698,7 +703,7 @@ private fun BookInfoHeader(
                         showLoadingPlaceholder = sharedCoverKey == null,
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
-                        sharedCoverKey = sharedCoverKey,
+                        sharedCoverKey = sharedCoverKey
                     )
                 }
                 Column(
@@ -708,17 +713,40 @@ private fun BookInfoHeader(
                         .padding(top = 8.dp, bottom = 8.dp),
                     verticalArrangement = Arrangement.spacedBy(4.dp)
                 ) {
-                    AppText(
-                        text = book.name,
-                        style = LegadoTheme.typography.headlineSmall,
-                        fontWeight = FontWeight.Bold,
-                        maxLines = 3,
-                        modifier = Modifier.combinedClickable(
-                            onClick = { onBookNameClick(false) },
-                            onLongClick = { onBookNameClick(true) }
+                    var showTitleMenu by remember { mutableStateOf(false) }
+                    var isTitleExpanded by rememberSaveable { mutableStateOf(false) }
+                    Box {
+                        AnimatedTextLine(
+                            text = book.name,
+                            style = LegadoTheme.typography.headlineSmall,
+                            fontWeight = FontWeight.Bold,
+                            maxLines = if (isTitleExpanded) Int.MAX_VALUE else 2,
+                            modifier = Modifier.combinedClickable(
+                                onClick = { onBookNameClick(false) },
+                                onLongClick = { showTitleMenu = true }
+                            )
                         )
-                    )
-                    AppText(
+                        RoundDropdownMenu(
+                            expanded = showTitleMenu,
+                            onDismissRequest = { showTitleMenu = false }
+                        ) {
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.search),
+                                onClick = {
+                                    showTitleMenu = false
+                                    onBookNameClick(true)
+                                }
+                            )
+                            RoundDropdownMenuItem(
+                                text = stringResource(if (isTitleExpanded) R.string.collapse else R.string.expand),
+                                onClick = {
+                                    showTitleMenu = false
+                                    isTitleExpanded = !isTitleExpanded
+                                }
+                            )
+                        }
+                    }
+                    AnimatedTextLine(
                         text = stringResource(R.string.author_show, book.getRealAuthor()),
                         style = LegadoTheme.typography.bodyLarge,
                         color = LegadoTheme.colorScheme.onSurfaceVariant,
@@ -727,7 +755,7 @@ private fun BookInfoHeader(
                             onLongClick = { onAuthorClick(true) }
                         )
                     )
-                    AppText(
+                    AnimatedTextLine(
                         text = stringResource(R.string.origin_show, book.originName),
                         style = LegadoTheme.typography.labelMedium,
                         color = LegadoTheme.colorScheme.primary,
@@ -896,12 +924,12 @@ private fun BookInfoSummary(
             .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 120.dp),
         verticalArrangement = Arrangement.spacedBy(4.dp)
     ) {
-        AppText(
+        AnimatedTextLine(
             text = stringResource(R.string.toc_s, book.durChapterTitle ?: stringResource(R.string.loading)),
             style = LegadoTheme.typography.titleMedium,
             fontWeight = FontWeight.Bold,
         )
-        AppText(
+        AnimatedTextLine(
             text = stringResource(R.string.lasted_show, book.latestChapterTitle ?: ""),
             style = LegadoTheme.typography.bodyMedium,
             color = LegadoTheme.colorScheme.onSurfaceVariant,
@@ -911,7 +939,7 @@ private fun BookInfoSummary(
             horizontalArrangement = Arrangement.spacedBy(8.dp),
             verticalAlignment = Alignment.CenterVertically,
         ) {
-            AppText(
+            AnimatedTextLine(
                 text = stringResource(R.string.read_chapter_total, book.totalChapterNum),
                 style = LegadoTheme.typography.labelMedium,
                 color = LegadoTheme.colorScheme.primary,
@@ -921,14 +949,14 @@ private fun BookInfoSummary(
                 text = "|",
                 color = LegadoTheme.colorScheme.secondary
             )
-            AppText(
+            AnimatedTextLine(
                 text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1),
                 style = LegadoTheme.typography.labelMedium,
                 color = LegadoTheme.colorScheme.secondary,
             )
         }
         if (chapterList.isEmpty()) {
-            AppText(
+            AnimatedTextLine(
                 text = stringResource(R.string.error_load_toc),
                 style = LegadoTheme.typography.bodySmall,
                 color = LegadoTheme.colorScheme.error
@@ -942,12 +970,15 @@ private fun BookInfoSummary(
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
             ) {
                 Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
-                    AppText(text = remark, style = LegadoTheme.typography.labelMediumEmphasized)
+                    AnimatedTextLine(
+                        text = remark,
+                        style = LegadoTheme.typography.labelMediumEmphasized
+                    )
                 }
             }
         }
         Spacer(modifier = Modifier.height(4.dp))
-        AppText(
+        AnimatedTextLine(
             text = book.getDisplayIntro().orEmpty().ifBlank { stringResource(R.string.intro_show_null) },
             style = LegadoTheme.typography.bodyMedium,
         )
@@ -1065,3 +1096,46 @@ private fun BookInfoDialogs(
 
     AppLogSheet(show = state.showAppLogSheet, onDismissRequest = { onIntent(BookInfoIntent.DismissAppLogSheet) })
 }
+
+@Composable
+private fun RelatedBooksBanner(
+    title: String,
+    books: ImmutableList<SearchBook>,
+    onBookClick: (SearchBook, String?) -> Unit,
+    onMoreClick: () -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(bottom = 16.dp)
+    ) {
+        if (title.isNotBlank()) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 16.dp, vertical = 8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                AppText(
+                    text = title,
+                    style = LegadoTheme.typography.titleMedium,
+                    fontWeight = FontWeight.Bold,
+                    modifier = Modifier.weight(1f),
+                )
+                SmallTonalIconButton(
+                    onClick = onMoreClick,
+                    imageVector = Icons.AutoMirrored.Filled.ArrowForward,
+                    contentDescription = "more",
+                )
+            }
+        }
+        BannerModule(
+            books = books.map { io.legado.app.ui.main.homepage.HomepageBookItemUi(book = it) }
+                .toImmutableList(),
+            onClick = onBookClick,
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 8.dp),
+        )
+    }
+}

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

@@ -11,6 +11,7 @@ import coil.ImageLoader
 import coil.request.SuccessResult
 import io.legado.app.R
 import io.legado.app.base.BaseViewModel
+
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.BookType
@@ -19,6 +20,7 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.ReadRecordRepository
@@ -54,6 +56,7 @@ import io.legado.app.ui.main.MainIntent
 import io.legado.app.ui.widget.components.image.cover.buildCoverImageRequest
 import io.legado.app.utils.ArchiveUtils
 import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.postEvent
@@ -62,6 +65,9 @@ import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -70,6 +76,7 @@ import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import kotlinx.collections.immutable.toImmutableList
 import java.io.ByteArrayOutputStream
 
 class BookInfoViewModel(
@@ -97,6 +104,7 @@ class BookInfoViewModel(
         }
     private var currentChapterList: List<BookChapter> = emptyList()
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
+    private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
     private var currentKindLabels: List<String> = emptyList()
     private var currentGroupNames: String? = null
     private var currentHasCustomGroup = false
@@ -147,6 +155,7 @@ class BookInfoViewModel(
         }
         currentChapterList = emptyList()
         currentWebFiles = emptyList()
+        currentRelatedBooks = emptyList()
         currentKindLabels = emptyList()
         currentGroupNames = null
         currentHasCustomGroup = false
@@ -263,6 +272,9 @@ class BookInfoViewModel(
                     }
                 }
             }
+
+            is BookInfoIntent.RelatedBookClick -> onRelatedBookClick(intent.book)
+            is BookInfoIntent.RelatedBooksMore -> onRelatedBooksMore(intent.title, intent.url)
         }
     }
 
@@ -706,6 +718,7 @@ class BookInfoViewModel(
                     } else {
                         loadChapter(loadedBook, runPreUpdateJs)
                     }
+                    loadRelatedBooks(loadedBook, source)
                 }.onError {
                     AppLog.put("获取书籍信息失败\n${it.localizedMessage}", it)
                     context.toastOnUi(R.string.error_get_book_info)
@@ -732,6 +745,7 @@ class BookInfoViewModel(
         }.onSuccess {
             currentBook = it
             currentChapterList = toc
+            currentRelatedBooks = emptyList()
             currentGroupNames = null
             currentHasCustomGroup = false
             currentKindLabels = emptyList()
@@ -746,6 +760,7 @@ class BookInfoViewModel(
         currentBook = book
         currentChapterList = emptyList()
         currentWebFiles = emptyList()
+        currentRelatedBooks = emptyList()
         currentKindLabels = emptyList()
         currentGroupNames = null
         currentHasCustomGroup = false
@@ -762,6 +777,7 @@ class BookInfoViewModel(
                 if (chapters.isNotEmpty()) {
                     currentChapterList = chapters
                     syncUiState(isTocLoading = false)
+                    source?.let { loadRelatedBooks(book, it) }
                 } else {
                     loadChapter(book)
                 }
@@ -1263,6 +1279,7 @@ class BookInfoViewModel(
                 book = currentBook?.uiCopy(),
                 chapterList = currentChapterList,
                 webFiles = currentWebFiles,
+                relatedBooks = currentRelatedBooks.toImmutableList(),
                 kindLabels = currentKindLabels,
                 groupNames = currentGroupNames,
                 hasCustomGroup = currentHasCustomGroup,
@@ -1277,6 +1294,86 @@ class BookInfoViewModel(
         }
     }
 
+    private fun onRelatedBookClick(book: SearchBook) {
+        emitEffect(
+            BookInfoEffect.NavigateToBookInfo(
+                name = book.name,
+                author = book.author,
+                bookUrl = book.bookUrl,
+                origin = book.origin,
+                coverPath = book.coverUrl,
+            )
+        )
+    }
+
+    private fun onRelatedBooksMore(title: String, resolvedUrl: String) {
+        val source = bookSource ?: return
+        emitEffect(
+            BookInfoEffect.NavigateToExploreShow(
+                title = title,
+                sourceUrl = source.bookSourceUrl,
+                exploreUrl = resolvedUrl,
+            )
+        )
+    }
+
+    private fun loadRelatedBooks(book: Book, source: BookSource) {
+        val modulesJson = source.ruleBookInfo?.relatedBooks
+        if (modulesJson.isNullOrBlank()) {
+            currentRelatedBooks = emptyList()
+            syncUiState()
+            return
+        }
+        val modules = try {
+            GSON.fromJsonArray<RelatedBooksDef>(modulesJson)
+                .getOrNull()
+                ?.filter { !it.url.isNullOrBlank() }
+                ?.map { it.copy(url = it.url!!.replace(Regex("\\s"), "")) }
+                ?: emptyList()
+        } catch (e: Exception) {
+            emptyList()
+        }
+        if (modules.isEmpty()) {
+            currentRelatedBooks = emptyList()
+            syncUiState()
+            return
+        }
+        execute {
+            coroutineScope {
+                modules.map { def ->
+                    async {
+                        val (resolvedUrl, books) = try {
+                            resolveAndExplore(source, def.url!!, book)
+                        } catch (e: Exception) {
+                            def.url!! to emptyList()
+                        }
+                        RelatedBooksUi(
+                            key = def.key ?: def.title.orEmpty(),
+                            title = def.title.orEmpty(),
+                            url = def.url!!,
+                            resolvedUrl = resolvedUrl,
+                            books = books.filter { it.bookUrl != book.bookUrl }.toImmutableList(),
+                        )
+                    }
+                }.awaitAll().filter { it.books.isNotEmpty() }
+            }
+        }.onSuccess { result ->
+            currentRelatedBooks = result
+            syncUiState()
+        }.onError {
+            currentRelatedBooks = emptyList()
+            syncUiState()
+        }
+    }
+
+    private suspend fun resolveAndExplore(
+        source: BookSource,
+        url: String,
+        book: Book,
+    ): Pair<String, List<SearchBook>> {
+        return WebBook.exploreBookWithResolvedUrl(source, url, 1, book)
+    }
+
     private fun emitEffect(effect: BookInfoEffect) {
         _effects.tryEmit(effect)
     }
@@ -1298,3 +1395,9 @@ private val BookInfoWebFile.isSupported: Boolean
 
 private val BookInfoWebFile.isSupportDecompress: Boolean
     get() = AppPattern.archiveFileRegex.matches(name)
+
+private data class RelatedBooksDef(
+    val key: String? = null,
+    val title: String? = null,
+    val url: String? = null,
+)

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

@@ -637,14 +637,7 @@ class ReadMenu @JvmOverloads constructor(
                 id = "search",
                 iconRes = R.drawable.ic_search,
                 description = context.getString(R.string.search_content),
-                onClick = { runMenuOut{ callBack.openSearchActivity(null) } }
-            ),
-            ToolButton(
-                id = "translate",
-                iconRes = R.drawable.ic_translate,
-                description = context.getString(R.string.translate),
-                onClick = { runMenuOut { callBack.onTranslationClick() } },
-                onLongClick = { runMenuOut { callBack.onTranslationLongClick() } }
+                onClick = { runMenuOut { callBack.openSearchActivity(null) } }
             ),
             ToolButton(
                 id = "catalog",
@@ -706,7 +699,7 @@ class ReadMenu @JvmOverloads constructor(
             ToolButton(
                 id = "replace_badge",
                 iconRes = R.drawable.ic_find_replace,
-                description = context.getString(R.string.replace_purify),
+                description = context.getString(R.string.replace_purify_badge),
                 onLongClick = { runMenuOut { callBack.openReplaceRule() } },
                 onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
                 onClick = {  }
@@ -716,6 +709,13 @@ class ReadMenu @JvmOverloads constructor(
                 iconRes = R.drawable.ic_auto_page,
                 description = context.getString(R.string.auto_next_page),
                 onClick = { runMenuOut { callBack.autoPage() } }
+            ),
+            ToolButton(
+                id = "translate",
+                iconRes = R.drawable.ic_translate,
+                description = context.getString(R.string.translate),
+                onClick = { runMenuOut { callBack.onTranslationClick() } },
+                onLongClick = { runMenuOut { callBack.onTranslationLongClick() } }
             )
         )
     }

+ 15 - 2
app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt

@@ -114,7 +114,20 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
     }
 
     private fun getAllButtonIds(): List<String> {
-        return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "translate_chapter", "replace", "replace_badge")
+        return listOf(
+            "search",
+            "auto_page",
+            "catalog",
+            "read_aloud",
+            "setting",
+            "addBookmark",
+            "theme",
+            "prev_chapter",
+            "next_chapter",
+            "replace",
+            "replace_badge",
+            "translate"
+        )
     }
 
     private fun getButtonInfo(id: String): Pair<Int, String> {
@@ -128,7 +141,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
             "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch)
             "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter)
             "next_chapter" -> R.drawable.ic_next to getString(R.string.next_chapter)
-            "translate_chapter" -> R.drawable.ic_translate to getString(R.string.translate_chapter)
+            "translate" -> R.drawable.ic_translate to getString(R.string.translate)
             "replace" -> R.drawable.ic_find_replace to getString(R.string.replace_purify)
             "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge)
             else -> R.drawable.ic_help to id

+ 27 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -51,7 +51,9 @@ import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import io.legado.app.ui.theme.LegadoTheme
@@ -64,6 +66,7 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SearchBar
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.book.SearchBookListItem
+import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.button.SmallIconButton
 import io.legado.app.ui.widget.components.button.SmallTextButton
 import io.legado.app.ui.widget.components.card.NormalCard
@@ -97,6 +100,8 @@ fun SearchScreen(
 ) {
     val context = LocalContext.current
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
+    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     val listState = rememberLazyListState()
     val lifecycleOwner = LocalLifecycleOwner.current
     var queryInput by rememberSaveable { mutableStateOf(state.query) }
@@ -417,6 +422,10 @@ fun SearchScreen(
                                                 )
                                             )
                                         },
+                                        onLongClick = { book, coverKey ->
+                                            previewBook = book
+                                            previewSharedCoverKey = coverKey
+                                        },
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
                                         sharedCoverKey = sharedCoverKey
@@ -561,6 +570,24 @@ fun SearchScreen(
             Spacer(modifier = Modifier.height(20.dp))
         }
     }
+
+    val previewShelfState = previewBook?.let { book ->
+        state.results.find { it.book.bookUrl == book.bookUrl }?.shelfState
+            ?: BookShelfState.NOT_IN_SHELF
+    }
+    SearchBookPreviewSheet(
+        data = previewBook,
+        shelfState = previewShelfState,
+        sharedCoverKey = previewSharedCoverKey,
+        onDismissRequest = { previewBook = null },
+        onOpenDetail = { book, sharedCoverKey ->
+            previewBook = null
+            viewModel.onIntent(SearchIntent.OpenSearchBook(book, sharedCoverKey))
+        },
+        onAddToShelf = { book ->
+            viewModel.onAddToShelf(book)
+        },
+    )
 }
 
 private data class SearchFloatingSummary(

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -7,6 +7,7 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchRequest
 import io.legado.app.domain.usecase.BookShelfKey
@@ -37,6 +38,7 @@ class SearchViewModel(
     private val repository: SearchRepository,
     private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
     private val searchBooksUseCase: SearchBooksUseCase,
+    private val addToBookshelfUseCase: AddToBookshelfUseCase,
 ) : ViewModel() {
 
     private val _uiState = MutableStateFlow(
@@ -72,6 +74,12 @@ class SearchViewModel(
         observeQueryBookshelfHints()
     }
 
+    fun onAddToShelf(book: SearchBook) {
+        viewModelScope.launch {
+            addToBookshelfUseCase.execute(book)
+        }
+    }
+
     fun onIntent(intent: SearchIntent) {
         when (intent) {
             is SearchIntent.Initialize -> initialize(intent.key, intent.scopeRaw)

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

@@ -330,6 +330,7 @@ class BookSourceEditActivity :
             add(EditEntity("tocUrl", ir.tocUrl, R.string.rule_toc_url))
             add(EditEntity("canReName", ir.canReName, R.string.rule_can_re_name))
             add(EditEntity("downloadUrls", ir.downloadUrls, R.string.download_url_rule))
+            add(EditEntity("relatedBooks", ir.relatedBooks, R.string.related_books))
         }
         // 目录页
         val tr = bs.getTocRule()
@@ -520,6 +521,7 @@ class BookSourceEditActivity :
                 "canReName" -> bookInfoRule.canReName = it.value
                 "downloadUrls" -> bookInfoRule.downloadUrls =
                     viewModel.ruleComplete(it.value, bookInfoRule.init)
+                "relatedBooks" -> bookInfoRule.relatedBooks = it.value
             }
         }
         tocEntities.forEach {

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfig.kt

@@ -84,4 +84,9 @@ object CoverConfig {
         PreferKey.coverInfoOrientation,
         "0" // 0: vertical, 1: horizontal
     )
+
+    var exploreFilterState by prefDelegate(
+        PreferKey.exploreFilterState,
+        0
+    )
 }

+ 18 - 1
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt

@@ -25,13 +25,13 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
@@ -130,6 +130,23 @@ fun CoverConfigScreen(
                 )
             }
 
+                SplicedColumnGroup(title = stringResource(R.string.network_book_badge_setting)) {
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.network_book_badge_setting),
+                        selectedValue = CoverConfig.exploreFilterState.toString(),
+                        displayEntries = arrayOf(
+                            stringResource(R.string.filter_show_all),
+                            stringResource(R.string.filter_hide_in_shelf),
+                            stringResource(R.string.filter_hide_same_name_author),
+                            stringResource(R.string.filter_show_not_in_shelf_only)
+                        ),
+                        entryValues = arrayOf("0", "1", "2", "3"),
+                        onValueChange = {
+                            CoverConfig.exploreFilterState = it.toInt()
+                        }
+                    )
+                }
+
             SplicedColumnGroup(title = stringResource(R.string.day)) {
                 val coverCount = CoverConfig.defaultCover.split(",").filter { it.isNotBlank() }.size
                 ClickableSettingItem(

+ 11 - 18
app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt

@@ -9,15 +9,10 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Close
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
@@ -37,15 +32,13 @@ import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
 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.text.AppText
 import kotlinx.coroutines.launch
 import org.koin.androidx.compose.koinViewModel
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun BackgroundImageManageSheet(
-    show: Boolean,
-    isDarkTheme: Boolean,
+    isDarkTheme: Boolean?,
     onDismissRequest: () -> Unit,
     viewModel: ThemeConfigViewModel = koinViewModel()
 ) {
@@ -58,23 +51,23 @@ fun BackgroundImageManageSheet(
                 scope.launch {
                     viewModel.setBackgroundFromUri(
                         uri = it,
-                        isDarkTheme = isDarkTheme
+                        isDarkTheme = isDarkTheme == true
                     )
                 }
             }
         }
 
-    val currentPath = if (isDarkTheme) {
-        ThemeConfig.bgImageDark
-    } else {
-        ThemeConfig.bgImageLight
-    }
-
     AppModalBottomSheet(
-        show = show,
+        data = isDarkTheme,
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.background_image),
-    ) {
+    ) { isDark ->
+        val currentPath = if (isDark) {
+            ThemeConfig.bgImageDark
+        } else {
+            ThemeConfig.bgImageLight
+        }
+
         Column(
             modifier = Modifier
                 .fillMaxWidth()
@@ -122,7 +115,7 @@ fun BackgroundImageManageSheet(
                         )
                     }
                     SmallTonalIconButton(
-                        onClick = { viewModel.removeBackground(isDarkTheme) },
+                        onClick = { viewModel.removeBackground(isDark) },
                         modifier = Modifier
                             .align(Alignment.TopEnd)
                             .padding(8.dp)

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

@@ -54,6 +54,11 @@ fun NavIconManageSheet(
     var activeDest by remember { mutableStateOf<String?>(null) }
 
     val destinations = listOf(
+        NavIconDestination(
+            "home",
+            R.string.home,
+            ThemeConfig.navIconHome
+        ) { ThemeConfig.navIconHome = it },
         NavIconDestination("bookshelf", R.string.bookshelf, ThemeConfig.navIconBookshelf) { ThemeConfig.navIconBookshelf = it },
         NavIconDestination("explore", R.string.discovery, ThemeConfig.navIconExplore) { ThemeConfig.navIconExplore = it },
         NavIconDestination("rss", R.string.rss, ThemeConfig.navIconRss) { ThemeConfig.navIconRss = it },
@@ -84,7 +89,9 @@ fun NavIconManageSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.theme_config_nav_icons),
     ) {
-        Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
+        Column(modifier = Modifier
+            .fillMaxWidth()
+            .padding(bottom = 24.dp)) {
             Row(
                 modifier = Modifier
                     .fillMaxWidth()

+ 11 - 15
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt

@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
 import android.content.Context
 import android.content.Intent
 import android.graphics.Typeface
-import android.net.Uri
 import android.os.Handler
 import android.os.Looper
 import androidx.activity.compose.rememberLauncherForActivityResult
@@ -72,6 +71,7 @@ import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.viewinterop.AndroidView
 import androidx.constraintlayout.compose.ConstraintLayout
+import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.base.AppContextWrapper
@@ -133,7 +133,7 @@ fun ThemeConfigScreen(
 
     var fontFolderUri by remember {
         mutableStateOf(
-            context.getPrefString(PreferKey.fontFolder)?.let { Uri.parse(it) }
+            context.getPrefString(PreferKey.fontFolder)?.toUri()
         )
     }
 
@@ -672,6 +672,7 @@ fun ThemeConfigScreen(
             item {
                 SplicedColumnGroup(title = stringResource(R.string.theme_config_nav_icon_settings)) {
                     val customCount = listOf(
+                        ThemeConfig.navIconHome,
                         ThemeConfig.navIconBookshelf,
                         ThemeConfig.navIconExplore,
                         ThemeConfig.navIconRss,
@@ -722,20 +723,15 @@ fun ThemeConfigScreen(
     )
 
 
-    manageKey?.let { isDark ->
-        BackgroundImageManageSheet(
-            show = true,
-            isDarkTheme = isDark,
-            onDismissRequest = { manageKey = null }
-        )
-    }
+    BackgroundImageManageSheet(
+        isDarkTheme = manageKey,
+        onDismissRequest = { manageKey = null }
+    )
 
-    if (showNavIconSheet) {
-        NavIconManageSheet(
-            show = true,
-            onDismissRequest = { showNavIconSheet = false }
-        )
-    }
+    NavIconManageSheet(
+        show = showNavIconSheet,
+        onDismissRequest = { showNavIconSheet = false }
+    )
 
 
     LauncherIconPickerSheet(

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

@@ -21,6 +21,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.lifecycleScope
 import androidx.navigation3.runtime.NavKey
 import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.scene.SinglePaneSceneStrategy
 import androidx.navigation3.ui.NavDisplay
 import io.legado.app.BuildConfig
 import io.legado.app.R
@@ -177,6 +178,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
         SharedTransitionLayout {
             NavDisplay(
                 backStack = backStack,
+                sceneStrategies = listOf(SinglePaneSceneStrategy()),
                 transitionSpec = {
                     (slideIntoContainer(
                         towards = AnimatedContentTransitionScope.SlideDirection.Start,

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

@@ -420,6 +420,12 @@ fun MainActivity.mainEntryProvider(
             onOpenSearch = { keyword ->
                 onNavigateToRoute(MainRouteSearch(key = keyword))
             },
+            onNavigateToBookInfo = { name, author, bookUrl, origin, coverPath ->
+                onNavigateToRoute(MainRouteBookInfo(name, author, bookUrl, origin, coverPath))
+            },
+            onNavigateToExploreShow = { title, sourceUrl, exploreUrl ->
+                onNavigateToRoute(MainRouteExploreShow(title, sourceUrl, exploreUrl))
+            },
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = LocalNavAnimatedContentScope.current,
             sharedCoverKey = route.sharedCoverKey ?: bookCoverSharedElementKey(route.bookUrl),

+ 8 - 1
app/src/main/java/io/legado/app/ui/main/homepage/HomepageContract.kt

@@ -3,11 +3,18 @@ package io.legado.app.ui.main.homepage
 import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.domain.model.ModuleDef
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
+@Stable
+data class HomepageBookItemUi(
+    val book: SearchBook,
+    val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
+)
+
 @Stable
 data class HomepageUiState(
     val modules: ImmutableList<HomepageModuleUi> = persistentListOf(),
@@ -93,7 +100,7 @@ sealed interface ModuleLoadState {
 
     @Stable
     data class Loaded(
-        val books: ImmutableList<SearchBook>,
+        val books: ImmutableList<HomepageBookItemUi>,
         val hasMore: Boolean = false,
         val isLoadingMore: Boolean = false,
         val page: Int = 1

+ 58 - 17
app/src/main/java/io/legado/app/ui/main/homepage/HomepageScreen.kt

@@ -37,7 +37,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.derivedStateOf
@@ -56,6 +55,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.data.entities.SearchBook
 import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.homepage.modules.BannerModule
@@ -71,6 +71,7 @@ import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.LoadMoreFooter
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.book.SearchBookGridItem
+import io.legado.app.ui.widget.components.book.SearchBookPreviewSheet
 import io.legado.app.ui.widget.components.button.SmallTonalIconButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.icon.AppIcon
@@ -98,6 +99,8 @@ fun HomepageScreen(
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+    var previewBook by remember { mutableStateOf<SearchBook?>(null) }
+    var previewSharedCoverKey by remember { mutableStateOf<String?>(null) }
     // Removed allSets and browseSources as they are now part of uiState.manageState
     val scope = rememberCoroutineScope()
     val context = LocalContext.current
@@ -113,11 +116,7 @@ fun HomepageScreen(
     })
 
     val homeString = stringResource(R.string.home)
-    val currentTitle by remember(
-        layoutMode,
-        pagerState.currentPage,
-        selectedSets,
-    ) {
+    val currentTitle by remember(layoutMode, selectedSets) {
         derivedStateOf {
             if (layoutMode == 1) {
                 homeString
@@ -177,7 +176,10 @@ fun HomepageScreen(
                     if (layoutMode == 1 && selectedSets.isNotEmpty()) {
                         AppTabRow(
                             tabTitles = selectedSets.map { it.sourceName },
-                            selectedTabIndex = pagerState.currentPage,
+                            selectedTabIndex = pagerState.currentPage.coerceIn(
+                                0,
+                                selectedSets.size - 1
+                            ),
                             onTabSelected = { index ->
                                 scope.launch { pagerState.animateScrollToPage(index) }
                             }
@@ -223,6 +225,10 @@ fun HomepageScreen(
                         onErrorClick = { errorMsg = it },
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
+                        onBookLongClick = { book, sharedCoverKey ->
+                            previewBook = book
+                            previewSharedCoverKey = sharedCoverKey
+                        },
                     )
                 }
             }
@@ -241,7 +247,7 @@ fun HomepageScreen(
             onDismiss = { errorMsg = null },
             content = { msg ->
                 SelectionContainer {
-                    Text(
+                    AppText(
                         text = msg,
                         style = LegadoTheme.typography.bodyMedium,
                         modifier = Modifier
@@ -303,6 +309,23 @@ fun HomepageScreen(
             layoutMode = layoutMode,
             onLayoutModeChange = { viewModel.setLayoutMode(it) },
         )
+
+        SearchBookPreviewSheet(
+            data = previewBook,
+            shelfState = previewBook?.let { viewModel.getCurrentBookShelfState(it) },
+            sharedCoverKey = previewSharedCoverKey,
+            onDismissRequest = { previewBook = null },
+            onOpenDetail = { book, sharedCoverKey ->
+                previewBook = null
+                onBookClick(
+                    book.name, book.author, book.bookUrl,
+                    book.origin, book.coverUrl, sharedCoverKey
+                )
+            },
+            onAddToShelf = { book ->
+                viewModel.onAddToShelf(book)
+            },
+        )
     }
 }
 
@@ -315,6 +338,7 @@ private fun ModuleList(
     gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
+    onBookLongClick: (SearchBook, String?) -> Unit = { _, _ -> },
     onErrorClick: (String) -> Unit
 ) {
     if (modules.isEmpty()) {
@@ -490,14 +514,20 @@ private fun ModuleList(
                             HomepageModuleType.Waterfall -> {
                                 itemsIndexed(
                                     state.books,
-                                    key = { index, book -> "wf_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
+                                    key = { index, item -> "wf_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
                                     val sharedCoverKey = bookCoverSharedElementKey(
-                                        book.bookUrl,
+                                        item.book.bookUrl,
                                         "home:${moduleUi.globalId}:waterfall:$index"
                                     )
                                     WaterfallItem(
-                                        book = book,
-                                        onClick = { viewModel.onBookClick(book, sharedCoverKey) },
+                                        item = item,
+                                        onClick = {
+                                            viewModel.onBookClick(
+                                                item.book,
+                                                sharedCoverKey
+                                            )
+                                        },
+                                        onLongClick = onBookLongClick,
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
                                         sharedCoverKey = sharedCoverKey,
@@ -520,15 +550,21 @@ private fun ModuleList(
                             HomepageModuleType.InfiniteGrid -> {
                                 itemsIndexed(
                                     state.books,
-                                    key = { index, book -> "inf_grid_${moduleUi.globalId}_${book.bookUrl}_$index" }) { index, book ->
+                                    key = { index, item -> "inf_grid_${moduleUi.globalId}_${item.book.bookUrl}_$index" }) { index, item ->
                                     val sharedCoverKey = bookCoverSharedElementKey(
-                                        book.bookUrl,
+                                        item.book.bookUrl,
                                         "home:${moduleUi.globalId}:infinite:$index"
                                     )
                                     SearchBookGridItem(
-                                        book = book,
-                                        shelfState = io.legado.app.domain.model.BookShelfState.NOT_IN_SHELF,
-                                        onClick = { viewModel.onBookClick(book, sharedCoverKey) },
+                                        book = item.book,
+                                        shelfState = item.shelfState,
+                                        onClick = {
+                                            viewModel.onBookClick(
+                                                item.book,
+                                                sharedCoverKey
+                                            )
+                                        },
+                                        onLongClick = onBookLongClick,
                                         sharedTransitionScope = sharedTransitionScope,
                                         animatedVisibilityScope = animatedVisibilityScope,
                                         sharedCoverKey = sharedCoverKey
@@ -560,6 +596,7 @@ private fun ModuleList(
                                         onClick = { book, sharedCoverKey ->
                                             viewModel.onBookClick(book, sharedCoverKey)
                                         },
+                                        onLongClick = onBookLongClick,
                                         modifier = Modifier.fillMaxWidth(),
                                         columns = columns,
                                         maxRows = rows,
@@ -582,6 +619,7 @@ private fun ModuleList(
                                                 onClick = { book, sharedCoverKey ->
                                                     viewModel.onBookClick(book, sharedCoverKey)
                                                 },
+                                                onLongClick = onBookLongClick,
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
@@ -600,6 +638,7 @@ private fun ModuleList(
                                                 onClick = { book, sharedCoverKey ->
                                                     viewModel.onBookClick(book, sharedCoverKey)
                                                 },
+                                                onLongClick = onBookLongClick,
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,
@@ -618,6 +657,7 @@ private fun ModuleList(
                                                 onClick = { book, sharedCoverKey ->
                                                     viewModel.onBookClick(book, sharedCoverKey)
                                                 },
+                                                onLongClick = onBookLongClick,
                                                 modifier = Modifier.fillMaxWidth(),
                                                 rows = config["layout_rows"]?.toIntOrNull() ?: 4,
                                                 sharedTransitionScope = sharedTransitionScope,
@@ -637,6 +677,7 @@ private fun ModuleList(
                                                 onClick = { book, sharedCoverKey ->
                                                     viewModel.onBookClick(book, sharedCoverKey)
                                                 },
+                                                onLongClick = onBookLongClick,
                                                 modifier = Modifier.fillMaxWidth(),
                                                 sharedTransitionScope = sharedTransitionScope,
                                                 animatedVisibilityScope = animatedVisibilityScope,

+ 84 - 4
app/src/main/java/io/legado/app/ui/main/homepage/HomepageViewModel.kt

@@ -7,12 +7,17 @@ import io.legado.app.base.BaseViewModel
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.repository.BookSourceRepository
+import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.CustomSetItem
 import io.legado.app.domain.model.HomepageModuleType
 import io.legado.app.domain.model.ModuleDef
 import io.legado.app.domain.model.ModuleItem
+import io.legado.app.domain.usecase.AddToBookshelfUseCase
+import io.legado.app.domain.usecase.BookShelfKey
 import io.legado.app.domain.usecase.ExploreBooksUseCase
+import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.help.source.exploreKinds
 import io.legado.app.utils.GSON
@@ -41,10 +46,15 @@ class HomepageViewModel(
     application: Application,
     private val bookSourceRepository: BookSourceRepository,
     private val gateway: HomepageModulesGateway,
+    private val searchRepository: SearchRepository,
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val saveSearchBooksUseCase: SaveSearchBooksUseCase,
+    private val resolveBookShelfStateUseCase: ResolveBookShelfStateUseCase,
+    private val addToBookshelfUseCase: AddToBookshelfUseCase,
 ) : BaseViewModel(application) {
 
+    private val _bookshelf = MutableStateFlow<Set<BookShelfKey>>(emptySet())
+
     companion object {
         private const val CUSTOM_SET_URL_PREFIX = "custom://"
         private const val HOMEPAGE_DEFAULT_GRID_ROWS = 2
@@ -178,7 +188,7 @@ class HomepageViewModel(
         )
     }
 
-    private val displayModulesFlow = combine(
+    private val rawModulesFlow = combine(
         orderedModuleDefsFlow,
         _moduleContentStates,
         _bookSourcesCache,
@@ -214,6 +224,33 @@ class HomepageViewModel(
         }.toImmutableList()
     }
 
+    private val displayModulesFlow = combine(
+        rawModulesFlow,
+        _bookshelf
+    ) { modules, bookshelf ->
+        modules.map { module ->
+            val state = module.state
+            if (state is ModuleLoadState.Loaded) {
+                module.copy(
+                    state = state.copy(
+                        books = state.books.map { item ->
+                            item.copy(
+                                shelfState = resolveBookShelfStateUseCase.execute(
+                                    name = item.book.name,
+                                    author = item.book.author,
+                                    url = item.book.bookUrl,
+                                    shelf = bookshelf
+                                )
+                            )
+                        }.toImmutableList()
+                    )
+                )
+            } else {
+                module
+            }
+        }.toImmutableList()
+    }
+
     // 5. 最终 UI 状态
     val uiState: StateFlow<HomepageUiState> = combine(
         displayModulesFlow,
@@ -293,6 +330,12 @@ class HomepageViewModel(
                 }
             }
         }
+
+        viewModelScope.launch {
+            searchRepository.bookshelfKeys.collect { keys ->
+                _bookshelf.value = keys
+            }
+        }
     }
 
     private suspend fun syncModulesFromSource(source: BookSource) {
@@ -376,9 +419,20 @@ class HomepageViewModel(
                 val hasMore = isInfinite(module.type, module.layoutConfig) && books.isNotEmpty()
                 books to hasMore
             }.onSuccess { (books, hasMore) ->
+                val shelf = _bookshelf.value
                 _moduleContentStates.update {
                     it + (module.id to ModuleLoadState.Loaded(
-                        books.toImmutableList(),
+                        books.map { book ->
+                            HomepageBookItemUi(
+                                book = book,
+                                shelfState = resolveBookShelfStateUseCase.execute(
+                                    name = book.name,
+                                    author = book.author,
+                                    url = book.bookUrl,
+                                    shelf = shelf
+                                )
+                            )
+                        }.toImmutableList(),
                         hasMore = hasMore
                     ))
                 }
@@ -406,8 +460,19 @@ class HomepageViewModel(
                 _moduleContentStates.update { states ->
                     val lastState =
                         states[globalId] as? ModuleLoadState.Loaded ?: return@update states
-                    val existingUrls = lastState.books.map { it.bookUrl }.toSet()
-                    val deduped = result.books.filter { it.bookUrl !in existingUrls }
+                    val existingUrls = lastState.books.map { it.book.bookUrl }.toSet()
+                    val shelf = _bookshelf.value
+                    val deduped = result.books.filter { it.bookUrl !in existingUrls }.map { book ->
+                        HomepageBookItemUi(
+                            book = book,
+                            shelfState = resolveBookShelfStateUseCase.execute(
+                                name = book.name,
+                                author = book.author,
+                                url = book.bookUrl,
+                                shelf = shelf
+                            )
+                        )
+                    }
                     states + (globalId to ModuleLoadState.Loaded(
                         books = (lastState.books + deduped).toImmutableList(),
                         hasMore = deduped.isNotEmpty(), isLoadingMore = false, page = nextPage
@@ -772,6 +837,21 @@ class HomepageViewModel(
         }
     }
 
+    fun getCurrentBookShelfState(book: SearchBook): BookShelfState {
+        return resolveBookShelfStateUseCase.execute(
+            name = book.name,
+            author = book.author,
+            url = book.bookUrl,
+            shelf = _bookshelf.value
+        )
+    }
+
+    fun onAddToShelf(book: SearchBook) {
+        execute {
+            addToBookshelfUseCase.execute(book)
+        }
+    }
+
     fun onBookClick(book: SearchBook, sharedCoverKey: String?) {
         viewModelScope.launch {
             saveSearchBooksUseCase.save(book)

+ 2 - 2
app/src/main/java/io/legado/app/ui/main/homepage/manage/AddCustomModuleDialog.kt

@@ -29,7 +29,7 @@ import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.divider.PillHeaderDivider
 import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
-import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 
 data class AddDialogPrefill(
@@ -109,7 +109,7 @@ fun <T> AddCustomModuleDialog(
                 GlassCard(
                     containerColor = LegadoTheme.colorScheme.onSheetContent
                 ) {
-                    DropdownListSettingItem(
+                    CompactDropdownSettingItem(
                         title = stringResource(R.string.homepage_type_label),
                         selectedValue = type,
                         displayEntries = typeList.map { it.title }.toTypedArray(),

+ 62 - 17
app/src/main/java/io/legado/app/ui/main/homepage/modules/BannerModule.kt

@@ -3,27 +3,38 @@ package io.legado.app.ui.main.homepage.modules
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Shuffle
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import kotlinx.collections.immutable.ImmutableList
 
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 fun BannerModule(
-    books: ImmutableList<SearchBook>,
+    books: ImmutableList<HomepageBookItemUi>,
     onClick: (SearchBook, String?) -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -39,24 +50,58 @@ fun BannerModule(
             .fadingEdge(lazyListState, gradientWidth = 16.dp),
         horizontalArrangement = Arrangement.spacedBy(12.dp),
     ) {
-        itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
+        itemsIndexed(books, key = { index, item -> "${item.book.bookUrl}:$index" }) { index, item ->
+            val book = item.book
             val sharedCoverKey = bookCoverSharedElementKey(
                 book.bookUrl,
                 sharedCoverKeySourceId?.let { "$it:$index" }
             )
-            CoilBookCover(
-                name = book.name,
-                author = book.author,
-                path = book.coverUrl,
-                radius = 12.dp,
-                sourceOrigin = book.origin,
-                modifier = Modifier
-                    .width(96.dp)
-                    .clickable { onClick(book, sharedCoverKey) },
-                sharedTransitionScope = sharedTransitionScope,
-                animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = sharedCoverKey
-            )
+            Box {
+                CoilBookCover(
+                    name = book.name,
+                    author = book.author,
+                    path = book.coverUrl,
+                    radius = 12.dp,
+                    sourceOrigin = book.origin,
+                    modifier = Modifier
+                        .width(96.dp)
+                        .combinedClickable(
+                            onClick = { onClick(book, sharedCoverKey) },
+                            onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+                        )
+                        .then(
+                            with(sharedTransitionScope) {
+                                if (this != null) {
+                                    Modifier.sharedBounds(
+                                        sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                                        animatedVisibilityScope = animatedVisibilityScope
+                                            ?: return@with Modifier,
+                                    )
+                                } else Modifier
+                            }
+                        ),
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                    sharedCoverKey = sharedCoverKey,
+                )
+
+                val shelfIcon = when (item.shelfState) {
+                    BookShelfState.IN_SHELF -> Icons.Default.Check
+                    BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle
+                    else -> null
+                }
+                if (shelfIcon != null) {
+                    TextCard(
+                        icon = shelfIcon,
+                        modifier = Modifier
+                            .align(Alignment.TopEnd)
+                            .padding(4.dp),
+                        cornerRadius = 4.dp,
+                        horizontalPadding = 2.dp,
+                        verticalPadding = 2.dp
+                    )
+                }
+            }
         }
     }
 }

+ 61 - 17
app/src/main/java/io/legado/app/ui/main/homepage/modules/CardModule.kt

@@ -3,9 +3,11 @@ package io.legado.app.ui.main.homepage.modules
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -15,15 +17,22 @@ import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Shuffle
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.collections.immutable.ImmutableList
@@ -31,11 +40,12 @@ import kotlinx.collections.immutable.ImmutableList
 /**
  * 卡片模块:横向滚动的推荐卡片
  */
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 fun CardModule(
-    books: ImmutableList<SearchBook>,
+    books: ImmutableList<HomepageBookItemUi>,
     onClick: (SearchBook, String?) -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -50,7 +60,8 @@ fun CardModule(
             .fadingEdge(lazyListState, gradientWidth = 8.dp),
         horizontalArrangement = Arrangement.spacedBy(12.dp),
     ) {
-        itemsIndexed(books, key = { index, book -> "${book.bookUrl}:$index" }) { index, book ->
+        itemsIndexed(books, key = { index, item -> "${item.book.bookUrl}:$index" }) { index, item ->
+            val book = item.book
             val sharedCoverKey = bookCoverSharedElementKey(
                 book.bookUrl,
                 sharedCoverKeySourceId?.let { "$it:$index" }
@@ -60,20 +71,53 @@ fun CardModule(
                     .width(120.dp)
                     .clip(RoundedCornerShape(16.dp))
                     .background(LegadoTheme.colorScheme.surfaceContainerLow)
-                    .clickable { onClick(book, sharedCoverKey) }
+                    .combinedClickable(
+                        onClick = { onClick(book, sharedCoverKey) },
+                        onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+                    )
+                    .then(
+                        with(sharedTransitionScope) {
+                            if (this != null) {
+                                Modifier.sharedBounds(
+                                    sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                                    animatedVisibilityScope = animatedVisibilityScope
+                                        ?: return@with Modifier,
+                                )
+                            } else Modifier
+                        }
+                    )
             ) {
-                CoilBookCover(
-                    name = book.name,
-                    author = book.author,
-                    path = book.coverUrl,
-                    radius = 16.dp,
-                    sourceOrigin = book.origin,
-                    modifier = Modifier
-                        .wrapContentWidth(),
-                    sharedTransitionScope = sharedTransitionScope,
-                    animatedVisibilityScope = animatedVisibilityScope,
-                    sharedCoverKey = sharedCoverKey
-                )
+                Box {
+                    CoilBookCover(
+                        name = book.name,
+                        author = book.author,
+                        path = book.coverUrl,
+                        radius = 16.dp,
+                        sourceOrigin = book.origin,
+                        modifier = Modifier
+                            .wrapContentWidth(),
+                        sharedTransitionScope = sharedTransitionScope,
+                        animatedVisibilityScope = animatedVisibilityScope,
+                        sharedCoverKey = sharedCoverKey,
+                    )
+
+                    val shelfIcon = when (item.shelfState) {
+                        BookShelfState.IN_SHELF -> Icons.Default.Check
+                        BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle
+                        else -> null
+                    }
+                    if (shelfIcon != null) {
+                        TextCard(
+                            icon = shelfIcon,
+                            modifier = Modifier
+                                .align(Alignment.TopEnd)
+                                .padding(8.dp),
+                            cornerRadius = 4.dp,
+                            horizontalPadding = 2.dp,
+                            verticalPadding = 2.dp
+                        )
+                    }
+                }
 
                 AppText(
                     text = book.name,

+ 10 - 9
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridModule.kt

@@ -11,17 +11,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
-import io.legado.app.data.entities.SearchBook
-import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.widget.components.book.SearchBookGridItem
 import kotlinx.collections.immutable.ImmutableList
 
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun GridModule(
-    books: ImmutableList<SearchBook>,
-    onClick: (SearchBook, String?) -> Unit,
+    books: ImmutableList<HomepageBookItemUi>,
+    onClick: (io.legado.app.data.entities.SearchBook, String?) -> Unit,
+    onLongClick: ((io.legado.app.data.entities.SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     columns: Int = 3,
     maxRows: Int? = null,
@@ -43,16 +43,17 @@ fun GridModule(
                 modifier = Modifier.fillMaxWidth(),
                 horizontalArrangement = Arrangement.spacedBy(12.dp),
             ) {
-                for ((columnIndex, book) in row.withIndex()) {
+                for ((columnIndex, item) in row.withIndex()) {
                     val itemIndex = rowIndex * columns + columnIndex
                     val sharedCoverKey = bookCoverSharedElementKey(
-                        book.bookUrl,
+                        item.book.bookUrl,
                         sharedCoverKeySourceId?.let { "$it:$itemIndex" }
                     )
                     SearchBookGridItem(
-                        book = book,
-                        shelfState = BookShelfState.NOT_IN_SHELF,
-                        onClick = { onClick(book, sharedCoverKey) },
+                        book = item.book,
+                        shelfState = item.shelfState,
+                        onClick = { onClick(item.book, sharedCoverKey) },
+                        onLongClick = onLongClick,
                         modifier = Modifier.weight(1f),
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,

+ 66 - 21
app/src/main/java/io/legado/app/ui/main/homepage/modules/GridRankingModule.kt

@@ -3,7 +3,9 @@ package io.legado.app.ui.main.homepage.modules
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
@@ -15,6 +17,9 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Shuffle
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -25,10 +30,13 @@ import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.collections.immutable.ImmutableList
@@ -36,8 +44,9 @@ import kotlinx.collections.immutable.ImmutableList
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun GridRankingModule(
-    books: ImmutableList<SearchBook>,
+    books: ImmutableList<HomepageBookItemUi>,
     onClick: (SearchBook, String?) -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     rows: Int = 4,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -71,16 +80,17 @@ fun GridRankingModule(
                     .fillMaxWidth()
                     .padding(vertical = 12.dp, horizontal = 12.dp)
             ) {
-                for ((rowIndex, book) in page.withIndex()) {
+                for ((rowIndex, item) in page.withIndex()) {
                     val itemIndex = pageIndex * rows + rowIndex
                     val sharedCoverKey = bookCoverSharedElementKey(
-                        book.bookUrl,
+                        item.book.bookUrl,
                         sharedCoverKeySourceId?.let { "$it:$itemIndex" }
                     )
                     GridRankingItem(
-                        rank = pages.flatten().indexOf(book) + 1,
-                        book = book,
-                        onClick = { onClick(book, sharedCoverKey) },
+                        rank = pages.flatten().indexOf(item) + 1,
+                        item = item,
+                        onClick = { onClick(item.book, sharedCoverKey) },
+                        onLongClick = onLongClick,
                         sharedTransitionScope = sharedTransitionScope,
                         animatedVisibilityScope = animatedVisibilityScope,
                         sharedCoverKey = sharedCoverKey,
@@ -95,35 +105,70 @@ fun GridRankingModule(
     }
 }
 
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 private fun GridRankingItem(
     rank: Int,
-    book: SearchBook,
+    item: HomepageBookItemUi,
     onClick: () -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
 ) {
+    val book = item.book
     Row(
         modifier = Modifier
             .fillMaxWidth()
             .clip(RoundedCornerShape(8.dp))
-            .clickable(onClick = onClick)
-            .padding(vertical = 4.dp, horizontal = 4.dp),
+            .combinedClickable(
+                onClick = onClick,
+                onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+            )
+            .padding(vertical = 4.dp, horizontal = 4.dp)
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                            animatedVisibilityScope = animatedVisibilityScope
+                                ?: return@with Modifier,
+                        )
+                    } else Modifier
+                }
+            ),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         // 1. 封面
-        CoilBookCover(
-            name = book.name,
-            author = book.author,
-            path = book.coverUrl,
-            sourceOrigin = book.origin,
-            modifier = Modifier.width(48.dp),
-            sharedTransitionScope = sharedTransitionScope,
-            animatedVisibilityScope = animatedVisibilityScope,
-            sharedCoverKey = sharedCoverKey
-        )
+        Box {
+            CoilBookCover(
+                name = book.name,
+                author = book.author,
+                path = book.coverUrl,
+                sourceOrigin = book.origin,
+                modifier = Modifier.width(48.dp),
+                sharedTransitionScope = sharedTransitionScope,
+                animatedVisibilityScope = animatedVisibilityScope,
+                sharedCoverKey = sharedCoverKey,
+            )
+
+            val shelfIcon = when (item.shelfState) {
+                BookShelfState.IN_SHELF -> Icons.Default.Check
+                BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle
+                else -> null
+            }
+            if (shelfIcon != null) {
+                TextCard(
+                    icon = shelfIcon,
+                    modifier = Modifier
+                        .align(Alignment.TopEnd)
+                        .padding(2.dp),
+                    cornerRadius = 4.dp,
+                    horizontalPadding = 2.dp,
+                    verticalPadding = 2.dp
+                )
+            }
+        }
 
         // 2. 排名
         AppText(

+ 30 - 9
app/src/main/java/io/legado/app/ui/main/homepage/modules/RankingModule.kt

@@ -4,7 +4,9 @@ import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
@@ -29,8 +31,8 @@ import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.data.entities.SearchBook
-import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.main.bookCoverSharedElementKey
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.book.SearchBookListItem
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -44,8 +46,9 @@ private const val MAX_COUNT = 20
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 fun RankingModule(
-    books: ImmutableList<SearchBook>,
+    books: ImmutableList<HomepageBookItemUi>,
     onClick: (SearchBook, String?) -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -66,15 +69,17 @@ fun RankingModule(
                 .animateContentSize()
         ) {
             // 显示书籍列表
-            displayBooks.forEachIndexed { index, book ->
+            displayBooks.forEachIndexed { index, item ->
                 RankingItem(
                     rank = index + 1,
-                    book = book,
+                    book = item.book,
+                    shelfState = item.shelfState,
                     onClick = onClick,
+                    onLongClick = onLongClick,
                     sharedTransitionScope = sharedTransitionScope,
                     animatedVisibilityScope = animatedVisibilityScope,
                     sharedCoverKey = bookCoverSharedElementKey(
-                        book.bookUrl,
+                        item.book.bookUrl,
                         sharedCoverKeySourceId?.let { "$it:$index" }
                     )
                 )
@@ -114,12 +119,14 @@ fun RankingModule(
     }
 }
 
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 private fun RankingItem(
     rank: Int,
     book: SearchBook,
+    shelfState: io.legado.app.domain.model.BookShelfState,
     onClick: (SearchBook, String?) -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
@@ -127,8 +134,22 @@ private fun RankingItem(
     Row(
         modifier = Modifier
             .fillMaxWidth()
-            .clickable { onClick(book, sharedCoverKey) }
-            .padding(vertical = 4.dp, horizontal = 4.dp),
+            .combinedClickable(
+                onClick = { onClick(book, sharedCoverKey) },
+                onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+            )
+            .padding(vertical = 4.dp, horizontal = 4.dp)
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                            animatedVisibilityScope = animatedVisibilityScope
+                                ?: return@with Modifier,
+                        )
+                    } else Modifier
+                }
+            ),
         verticalAlignment = Alignment.CenterVertically,
     ) {
         AppText(
@@ -144,7 +165,7 @@ private fun RankingItem(
         )
         SearchBookListItem(
             book = book,
-            shelfState = BookShelfState.NOT_IN_SHELF,
+            shelfState = shelfState,
             onClick = null,
             showPadding = false,
             modifier = Modifier.weight(1f),

+ 57 - 16
app/src/main/java/io/legado/app/ui/main/homepage/modules/WaterfallModule.kt

@@ -3,23 +3,32 @@ package io.legado.app.ui.main.homepage.modules
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.FlowRow
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 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.Shuffle
 import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
+import io.legado.app.ui.main.homepage.HomepageBookItemUi
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.book.SearchBookTagChip
 import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
 
@@ -27,36 +36,68 @@ import io.legado.app.ui.widget.components.text.AppText
  * 瀑布流单项组件
  * 建议直接在 LazyVerticalStaggeredGrid 的 items 中使用,以获得最佳回收性能
  */
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 fun WaterfallItem(
-    book: SearchBook,
+    item: HomepageBookItemUi,
     onClick: () -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
 ) {
+    val book = item.book
     GlassCard(
+        modifier = with(sharedTransitionScope) {
+            if (this != null) {
+                Modifier.sharedBounds(
+                    sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                    animatedVisibilityScope = animatedVisibilityScope ?: return@with Modifier,
+                )
+            } else Modifier
+        },
         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
     ) {
         Column(
             modifier = modifier
                 .fillMaxWidth()
-                .clickable(onClick = onClick)
+                .combinedClickable(
+                    onClick = onClick,
+                    onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+                )
         ) {
-            CoilBookCover(
-                name = book.name,
-                author = book.author,
-                path = book.coverUrl,
-                radius = 16.dp,
-                sourceOrigin = book.origin,
-                modifier = Modifier
-                    .fillMaxWidth(),
-                sharedTransitionScope = sharedTransitionScope,
-                animatedVisibilityScope = animatedVisibilityScope,
-                sharedCoverKey = sharedCoverKey
-            )
+            Box {
+                CoilBookCover(
+                    name = book.name,
+                    author = book.author,
+                    path = book.coverUrl,
+                    radius = 16.dp,
+                    sourceOrigin = book.origin,
+                    modifier = Modifier
+                        .fillMaxWidth(),
+                    sharedTransitionScope = sharedTransitionScope,
+                    animatedVisibilityScope = animatedVisibilityScope,
+                    sharedCoverKey = sharedCoverKey,
+                )
+
+                val shelfIcon = when (item.shelfState) {
+                    BookShelfState.IN_SHELF -> Icons.Default.Check
+                    BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle
+                    else -> null
+                }
+                if (shelfIcon != null) {
+                    TextCard(
+                        icon = shelfIcon,
+                        modifier = Modifier
+                            .align(Alignment.TopEnd)
+                            .padding(6.dp),
+                        cornerRadius = 4.dp,
+                        horizontalPadding = 2.dp,
+                        verticalPadding = 2.dp
+                    )
+                }
+            }
 
             Spacer(modifier = Modifier.height(8.dp))
 

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

@@ -5,8 +5,11 @@ import android.graphics.Bitmap
 import android.graphics.drawable.BitmapDrawable
 import android.graphics.drawable.Drawable
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.produceState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.platform.LocalContext
 import androidx.core.graphics.drawable.toBitmap
@@ -55,15 +58,20 @@ fun rememberImageSeedColor(
     configureRequest: ImageRequest.Builder.() -> Unit = {},
 ): Color? {
     val context = LocalContext.current
-    val seedColor by produceState<Color?>(initialValue = null, imageLoader, requestKey) {
-        value = if (data == null) {
-            null
+    var seedColor by remember { mutableStateOf<Color?>(null) }
+
+    LaunchedEffect(imageLoader, requestKey) {
+        if (data == null) {
+            seedColor = null
         } else {
-            imageLoader.extractSeedColor(
+            val extracted = imageLoader.extractSeedColor(
                 context = context,
                 data = data,
                 configureRequest = configureRequest
             )
+            if (extracted != null) {
+                seedColor = extracted
+            }
         }
     }
     return seedColor

+ 38 - 5
app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt

@@ -3,7 +3,8 @@ package io.legado.app.ui.widget.components.book
 import androidx.compose.animation.AnimatedVisibilityScope
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.IntrinsicSize
@@ -40,12 +41,13 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.text.AppText
 
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 fun SearchBookListItem(
     book: SearchBook,
     shelfState: BookShelfState,
     onClick: (() -> Unit)?,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     showPadding: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -55,8 +57,24 @@ fun SearchBookListItem(
     Row(
         modifier = modifier
             .fillMaxWidth()
-            .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+            .then(
+                if (onClick != null || onLongClick != null) Modifier.combinedClickable(
+                    onClick = onClick ?: {},
+                    onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+                ) else Modifier
+            )
             .then(if (showPadding) Modifier.adaptiveHorizontalPadding(vertical = 8.dp) else Modifier)
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                            animatedVisibilityScope = animatedVisibilityScope
+                                ?: return@with Modifier,
+                        )
+                    } else Modifier
+                }
+            )
     ) {
         Box(modifier = Modifier
             .width(72.dp)
@@ -166,12 +184,13 @@ fun SearchBookListItem(
     }
 }
 
-@OptIn(ExperimentalSharedTransitionApi::class)
+@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalFoundationApi::class)
 @Composable
 fun SearchBookGridItem(
     book: SearchBook,
     shelfState: BookShelfState,
     onClick: () -> Unit,
+    onLongClick: ((SearchBook, String?) -> Unit)? = null,
     modifier: Modifier = Modifier,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
@@ -181,7 +200,21 @@ fun SearchBookGridItem(
         modifier = modifier
             .width(IntrinsicSize.Min)
             .clip(RoundedCornerShape(4.dp))
-            .clickable(onClick = onClick)
+            .combinedClickable(
+                onClick = onClick,
+                onLongClick = onLongClick?.let { cb -> { cb(book, sharedCoverKey) } }
+            )
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null) {
+                        Modifier.sharedBounds(
+                            sharedContentState = rememberSharedContentState("preview:${book.bookUrl}"),
+                            animatedVisibilityScope = animatedVisibilityScope
+                                ?: return@with Modifier,
+                        )
+                    } else Modifier
+                }
+            )
     ) {
         Box(
             modifier = Modifier

+ 186 - 0
app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookPreviewSheet.kt

@@ -0,0 +1,186 @@
+package io.legado.app.ui.widget.components.book
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.BookShelfState
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.fadingEdge
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun SearchBookPreviewSheet(
+    data: SearchBook?,
+    shelfState: BookShelfState? = null,
+    sharedCoverKey: String? = null,
+    onDismissRequest: () -> Unit,
+    onOpenDetail: (SearchBook, String?) -> Unit,
+    onAddToShelf: (SearchBook) -> Unit,
+) {
+    AppModalBottomSheet(
+        data = data,
+        onDismissRequest = onDismissRequest,
+    ) { book ->
+        val isInShelf = shelfState == BookShelfState.IN_SHELF
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .verticalScroll(rememberScrollState())
+                .padding(bottom = 24.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(16.dp),
+                verticalAlignment = Alignment.Top,
+            ) {
+                Box(
+                    modifier = Modifier
+                        .width(120.dp)
+                        .aspectRatio(5f / 7f)
+                ) {
+                    CoilBookCover(
+                        name = book.name,
+                        author = book.author,
+                        path = book.coverUrl,
+                        sourceOrigin = book.origin,
+                        modifier = Modifier
+                            .width(120.dp)
+                            .aspectRatio(5f / 7f),
+                    )
+                }
+
+                Column(
+                    modifier = Modifier
+                        .weight(1f)
+                        .align(Alignment.CenterVertically),
+                    verticalArrangement = Arrangement.spacedBy(4.dp)
+                ) {
+                    AppText(
+                        text = book.name,
+                        style = LegadoTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                        maxLines = 3,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+
+                    if (book.author.isNotBlank()) {
+                        AppText(
+                            text = book.author,
+                            style = LegadoTheme.typography.bodyLarge,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                    }
+
+                    if (book.originName.isNotBlank()) {
+                        AppText(
+                            text = book.originName,
+                            style = LegadoTheme.typography.labelMedium,
+                            color = LegadoTheme.colorScheme.primary,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                    }
+
+                    val latestChapter = book.latestChapterTitle
+                    if (!latestChapter.isNullOrBlank()) {
+                        AppText(
+                            text = latestChapter,
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                            overflow = TextOverflow.Ellipsis,
+                        )
+                    }
+
+                    if (book.time > 0) {
+                        val dateFormat = remember {
+                            SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault())
+                        }
+                        AppText(
+                            text = dateFormat.format(Date(book.time)),
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
+                            maxLines = 1,
+                        )
+                    }
+                }
+            }
+
+            val kinds = book.getKindList()
+            if (kinds.isNotEmpty()) {
+                Spacer(modifier = Modifier.height(12.dp))
+                val lazyListState = rememberLazyListState()
+                LazyRow(
+                    state = lazyListState,
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .fadingEdge(lazyListState, gradientWidth = 8.dp),
+                    horizontalArrangement = Arrangement.spacedBy(6.dp),
+                ) {
+                    items(kinds) { kind ->
+                        SearchBookTagChip(text = kind)
+                    }
+                }
+            }
+
+            val intro = book.intro?.replace("\\s+".toRegex(), " ")?.trim()
+            if (!intro.isNullOrBlank()) {
+                Spacer(modifier = Modifier.height(12.dp))
+                Box(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .heightIn(max = 160.dp)
+                        .verticalScroll(rememberScrollState())
+                ) {
+                    AppText(
+                        text = intro,
+                        style = LegadoTheme.typography.labelMediumEmphasized,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+
+            Spacer(modifier = Modifier.height(16.dp))
+
+            ConfirmDismissButtonsRow(
+                onDismiss = { onOpenDetail(book, sharedCoverKey) },
+                onConfirm = { onAddToShelf(book) },
+                dismissText = stringResource(R.string.book_info),
+                confirmText = if (isInShelf) stringResource(R.string.already_in_bookshelf)
+                else stringResource(R.string.add_to_bookshelf),
+                confirmEnabled = !isInShelf,
+            )
+        }
+    }
+}

+ 25 - 5
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt

@@ -114,17 +114,34 @@ fun ExploreKindMultiTypeItem(
                     state,
                     valueOverride,
                     onValueChange,
+                    onRunAction,
                     modifier,
                     backgroundColor
                 )
             }
 
             ExploreKind.Type.toggle -> {
-                ToggleTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem)
+                ToggleTypeItem(
+                    kind,
+                    sourceUrl,
+                    state,
+                    valueOverride,
+                    onValueChange,
+                    onRunAction,
+                    BaseItem
+                )
             }
 
             ExploreKind.Type.select -> {
-                SelectTypeItem(kind, sourceUrl, state, valueOverride, onValueChange, BaseItem)
+                SelectTypeItem(
+                    kind,
+                    sourceUrl,
+                    state,
+                    valueOverride,
+                    onValueChange,
+                    onRunAction,
+                    BaseItem
+                )
             }
 
             else -> {
@@ -194,6 +211,7 @@ private fun TextTypeItem(
     state: ExploreKindItemState,
     valueOverride: String?,
     onValueChange: ((String) -> Unit)?,
+    onRunAction: (() -> Unit)?,
     modifier: Modifier,
     backgroundColor: Color
 ) {
@@ -214,7 +232,7 @@ private fun TextTypeItem(
                 actionJob?.cancel()
                 actionJob = scope.launch {
                     delay(600)
-                    state.executeAction(kind.action)
+                    if (onRunAction != null) onRunAction() else state.executeAction(kind.action)
                 }
             }
         },
@@ -231,6 +249,7 @@ private fun ToggleTypeItem(
     state: ExploreKindItemState,
     valueOverride: String?,
     onValueChange: ((String) -> Unit)?,
+    onRunAction: (() -> Unit)?,
     BaseItem: @Composable (String, () -> Unit) -> Unit
 ) {
     val chars = remember(kind.chars) {
@@ -256,7 +275,7 @@ private fun ToggleTypeItem(
         val nextIndex = if (currentIndex < 0) 0 else (currentIndex + 1) % chars.size
         char = chars.getOrElse(nextIndex) { "" }
         state.updateValue(char, onValueChange)
-        state.executeAction(kind.action)
+        if (onRunAction != null) onRunAction() else state.executeAction(kind.action)
     }
 
     BaseItem(text, internalOnClick)
@@ -269,6 +288,7 @@ private fun SelectTypeItem(
     state: ExploreKindItemState,
     valueOverride: String?,
     onValueChange: ((String) -> Unit)?,
+    onRunAction: (() -> Unit)?,
     BaseItem: @Composable (String, () -> Unit) -> Unit
 ) {
     val chars = remember(kind.chars) {
@@ -307,7 +327,7 @@ private fun SelectTypeItem(
                         if (selected != option) {
                             selected = option
                             state.updateValue(option, onValueChange)
-                            state.executeAction(kind.action)
+                            if (onRunAction != null) onRunAction() else state.executeAction(kind.action)
                         }
                     }
                 )

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

@@ -26,7 +26,6 @@ import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
-import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
@@ -83,10 +82,16 @@ fun CoilBookCover(
     }
 
     val hasCustomDefault = !randomPath.isNullOrBlank()
-    var isOnlineCoverLoaded by remember(path, sharedCoverKey, finalPath) {
+    var isOnlineCoverLoaded by remember(finalPath) {
         mutableStateOf(sharedCoverKey != null && finalPath != null)
     }
 
+    LaunchedEffect(finalPath) {
+        if (finalPath == null) {
+            isOnlineCoverLoaded = false
+        }
+    }
+
     val transitionRadius = rememberSharedCoverTransitionRadius(
         sharedCoverKey = sharedCoverKey,
         radius = radius,
@@ -94,104 +99,103 @@ fun CoilBookCover(
     )
     val shape = remember(transitionRadius) { RoundedCornerShape(transitionRadius) }
 
-    key(path, sharedCoverKey) {
-        Box(
-            modifier = modifier
-                .aspectRatio(5f / 7f)
-                .then(
-                    with(sharedTransitionScope) {
-                        if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
-                            Modifier.sharedElement(
-                                sharedContentState = rememberSharedContentState(sharedCoverKey),
-                                animatedVisibilityScope = animatedVisibilityScope,
-                                clipInOverlayDuringTransition = OverlayClip(shape)
-                            )
-                        } else Modifier
-                    }
-                )
-                .then(
-                    if (CoverConfig.coverShowShadow) {
-                        Modifier.shadow(4.dp, shape)
+    Box(
+        modifier = modifier
+            .aspectRatio(5f / 7f)
+            .then(
+                with(sharedTransitionScope) {
+                    if (this != null && animatedVisibilityScope != null && sharedCoverKey != null) {
+                        Modifier.sharedElement(
+                            sharedContentState = rememberSharedContentState(sharedCoverKey),
+                            animatedVisibilityScope = animatedVisibilityScope,
+                            clipInOverlayDuringTransition = OverlayClip(shape)
+                        )
                     } else Modifier
-                )
-                .background(
-                    if (!hasCustomDefault && !isOnlineCoverLoaded) {
-                        LegadoTheme.colorScheme.surfaceContainerLow
-                    } else Color.Transparent,
-                    shape
-                )
-                .clip(shape)
-        ) {
-            Box(modifier = Modifier.fillMaxSize()) {
-                if (hasCustomDefault && !isOnlineCoverLoaded) {
-                    AsyncImage(
-                        model = buildCoverImageRequest(
-                            context = context,
-                            data = randomPath,
-                            sourceOrigin = null,
-                            loadOnlyWifi = false,
-                            crossfade = showLoadingPlaceholder,
-                            memoryCacheKey = randomPath,
-                        ),
-                        contentDescription = null,
-                        imageLoader = koinInject(),
-                        contentScale = ContentScale.Crop,
-                        modifier = Modifier
-                            .fillMaxSize()
-                    )
                 }
+            )
+            .then(
+                if (CoverConfig.coverShowShadow) {
+                    Modifier.shadow(4.dp, shape)
+                } else Modifier
+            )
+            .background(
+                if (!hasCustomDefault && !isOnlineCoverLoaded) {
+                    LegadoTheme.colorScheme.surfaceContainerLow
+                } else Color.Transparent,
+                shape
+            )
+            .clip(shape)
+    ) {
+        Box(modifier = Modifier.fillMaxSize()) {
+            if (hasCustomDefault && !isOnlineCoverLoaded) {
+                AsyncImage(
+                    model = buildCoverImageRequest(
+                        context = context,
+                        data = randomPath,
+                        sourceOrigin = null,
+                        loadOnlyWifi = false,
+                        crossfade = showLoadingPlaceholder,
+                        memoryCacheKey = randomPath,
+                    ),
+                    contentDescription = null,
+                    imageLoader = koinInject(),
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier
+                        .fillMaxSize()
+                )
+            }
 
-                if (finalPath != null) {
-                    AsyncImage(
-                        model = buildCoverImageRequest(
-                            context = context,
-                            data = finalPath,
-                            sourceOrigin = sourceOrigin,
-                            loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
-                            crossfade = showLoadingPlaceholder,
-                            memoryCacheKey = finalPath,
-                        ),
-                        contentDescription = null,
-                        imageLoader = koinInject(),
-                        contentScale = ContentScale.Crop,
-                        modifier = Modifier.fillMaxSize(),
-                        onSuccess = {
-                            isOnlineCoverLoaded = true
-                            onLoadFinish?.invoke()
-                        },
-                        onError = {
-                            isOnlineCoverLoaded = false
-                            onLoadFinish?.invoke()
-                        }
-                    )
-                } else {
-                    LaunchedEffect(Unit) {
+            if (finalPath != null) {
+                AsyncImage(
+                    model = buildCoverImageRequest(
+                        context = context,
+                        data = finalPath,
+                        sourceOrigin = sourceOrigin,
+                        loadOnlyWifi = CoverConfig.loadCoverOnlyWifi,
+                        crossfade = showLoadingPlaceholder,
+                        memoryCacheKey = finalPath,
+                    ),
+                    contentDescription = null,
+                    imageLoader = koinInject(),
+                    contentScale = ContentScale.Crop,
+                    modifier = Modifier.fillMaxSize(),
+                    onSuccess = {
+                        isOnlineCoverLoaded = true
+                        onLoadFinish?.invoke()
+                    },
+                    onError = {
+                        isOnlineCoverLoaded = false
                         onLoadFinish?.invoke()
                     }
+                )
+            } else {
+                LaunchedEffect(Unit) {
+                    onLoadFinish?.invoke()
                 }
             }
+        }
 
-            if (showLoadingPlaceholder && !isOnlineCoverLoaded) {
-                if (!hasCustomDefault) {
-                    Icon(
-                        Icons.Default.Book,
-                        contentDescription = null,
-                        tint = LegadoTheme.colorScheme.secondary,
-                        modifier = Modifier
-                            .fillMaxSize(0.35f)
-                            .align(Alignment.Center)
-                    )
-                }
-                CoverTextOverlay(
-                    name = name,
-                    author = author,
-                    isNight = isNight
+        if (showLoadingPlaceholder && !isOnlineCoverLoaded) {
+            if (!hasCustomDefault) {
+                Icon(
+                    Icons.Default.Book,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.secondary,
+                    modifier = Modifier
+                        .fillMaxSize(0.35f)
+                        .align(Alignment.Center)
                 )
             }
+            CoverTextOverlay(
+                name = name,
+                author = author,
+                isNight = isNight
+            )
         }
     }
 }
 
+
 @OptIn(ExperimentalSharedTransitionApi::class)
 @Composable
 private fun rememberSharedCoverTransitionRadius(

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

@@ -104,18 +104,18 @@ fun AppModalBottomSheet(
                 LocalWindowInfo.current.containerSize.height.toDp() * 0.8f
             }
 
-            ModalBottomSheet(
-                onDismissRequest = onDismissRequest,
-                sheetState = sheetState,
-                containerColor = sheetContainerColor,
-                contentColor = sheetContentColor,
-                dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) }
+            MaterialExpressiveTheme(
+                colorScheme = colorScheme,
+                typography = Typography(),
+                motionScheme = MotionScheme.expressive(),
+                shapes = Shapes()
             ) {
-                MaterialExpressiveTheme(
-                    colorScheme = colorScheme,
-                    typography = Typography(),
-                    motionScheme = MotionScheme.expressive(),
-                    shapes = Shapes()
+                ModalBottomSheet(
+                    onDismissRequest = onDismissRequest,
+                    sheetState = sheetState,
+                    containerColor = sheetContainerColor,
+                    contentColor = sheetContentColor,
+                    dragHandle = { BottomSheetDefaults.DragHandle(color = sheetDragHandleColor) }
                 ) {
                     Column(
                         modifier = Modifier

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

@@ -976,6 +976,7 @@
     <string name="pref_anti_alias_summary">绘制图片时抗锯齿</string>
     <string name="upload_url">上传 URL</string>
     <string name="download_url_rule">下载 URL 规则</string>
+    <string name="related_books">关联书籍(relatedBooks)</string>
     <string name="clipboard_empty">剪贴板没有内容</string>
     <string name="clipboard_empty_or_invalid">剪贴板为空或格式不正确</string>
     <string name="invalid_format">格式不正确</string>
@@ -1620,6 +1621,11 @@
     <string name="underline_padding">离字间距</string>
 	<string name="layout_setting">布局设置</string>
 	<string name="cover_info_orientation">封面信息方向</string>
+    <string name="network_book_badge_setting">网络书籍角标设置</string>
+    <string name="filter_show_all">全部显示</string>
+    <string name="filter_hide_in_shelf">隐藏已在书架的同源书籍</string>
+    <string name="filter_hide_same_name_author">隐藏已在书架的非同源书籍</string>
+    <string name="filter_show_not_in_shelf_only">只显示不在书架的书籍</string>
 	<string name="rule_segment">规则</string>
 	<string name="tool_bar_style">工具栏样式</string>
 	<string name="split_title">标题分段</string>

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

@@ -1007,6 +1007,7 @@
     <string name="pref_anti_alias_summary">Anti-Aliasing when draw picture</string>
     <string name="upload_url">upload URL</string>
     <string name="download_url_rule">downloadUrlRule(downloadUrls)</string>
+    <string name="related_books">Related Books(relatedBooks)</string>
     <string name="clipboard_empty">Clipboard is empty</string>
     <string name="clipboard_empty_or_invalid">Clipboard is empty or has an invalid format</string>
     <string name="invalid_format">Invalid format</string>
@@ -1655,6 +1656,11 @@
 
     <!-- Missing strings -->
     <string name="cover_info_orientation">Cover orientation</string>
+    <string name="network_book_badge_setting">Network Book Badge</string>
+    <string name="filter_show_all">Show All</string>
+    <string name="filter_hide_in_shelf">Hide Same Source Books in Shelf</string>
+    <string name="filter_hide_same_name_author">Hide Non-Same Source Books in Shelf</string>
+    <string name="filter_show_not_in_shelf_only">Show Only Books Not in Shelf</string>
     <string name="follow_read_background">Follow background</string>
     <string name="heading_spacing">Heading spacing</string>
     <string name="layout_setting">Layout settings</string>