Sfoglia il codice sorgente

更多自定义设置 @325506 (#994)

* 第一次上传

* 新增深度个性化设置-可以给主题选择自定义字体了

* 新增深度个性化设置-存在背景图片时,应用栏和导航栏会对背景图片进行模糊处理

* 新增深度个性化设置-主题颜色高度自定义

* 新增深度个性化设置-新增边框设置

* 合并后bug修复

# Conflicts:
#	app/build.gradle.kts
#	app/google-services.json
#	app/src/main/java/io/legado/app/constant/PreferKey.kt
#	app/src/main/java/io/legado/app/di/appModule.kt
#	app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
#	app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
#	app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt
#	app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
#	app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
#	app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/main/MainActivity.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
#	app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
#	app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
#	app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
#	app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
#	app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
#	app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt
#	app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
#	app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/Typography.kt
#	app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
#	app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
#	app/src/main/res/drawable/ic_add.xml
#	app/src/main/res/drawable/ic_add_online.xml
#	app/src/main/res/drawable/ic_arrange.xml
#	app/src/main/res/drawable/ic_arrow_down.xml
#	app/src/main/res/drawable/ic_arrow_right.xml
#	app/src/main/res/drawable/ic_cfg_about.xml
#	app/src/main/res/drawable/ic_cfg_backup.xml
#	app/src/main/res/drawable/ic_cfg_other.xml
#	app/src/main/res/drawable/ic_cfg_replace.xml
#	app/src/main/res/drawable/ic_cfg_source.xml
#	app/src/main/res/drawable/ic_cfg_theme.xml
#	app/src/main/res/drawable/ic_cfg_web.xml
#	app/src/main/res/drawable/ic_groups.xml
#	app/src/main/res/drawable/ic_help.xml
#	app/src/main/res/drawable/ic_more.xml
#	app/src/main/res/drawable/ic_search.xml
#	app/src/main/res/drawable/ic_view_quilt.xml
#	app/src/main/res/layout/dialog_tip_config.xml
#	app/src/main/res/layout/view_preference.xml
#	app/src/main/res/layout/view_preference_category.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rHK/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	app/src/main/res/values/arrays.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/xml/pref_main.xml
#	avd.sh
#	gradle.properties
#	gradle/libs.versions.toml
#	gradle/wrapper/gradle-wrapper.properties
#	gradlew
#	modules/web/index.html

* 边框设置bug修复

# Conflicts:
#	app/build.gradle.kts
#	app/google-services.json
#	app/src/main/java/io/legado/app/constant/PreferKey.kt
#	app/src/main/java/io/legado/app/di/appModule.kt
#	app/src/main/java/io/legado/app/ui/book/bookmark/AllBookmarkScreen.kt
#	app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
#	app/src/main/java/io/legado/app/ui/book/group/GroupEditSheet.kt
#	app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
#	app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/manga/config/MangaClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/read/config/ClickActionConfigDialog.kt
#	app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchActivity.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
#	app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt
#	app/src/main/java/io/legado/app/ui/book/toc/rule/TxtRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
#	app/src/main/java/io/legado/app/ui/config/coverConfig/CoverConfigScreen.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/CheckSourceBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/otherConfig/DirectLinkUploadBottomSheet.kt
#	app/src/main/java/io/legado/app/ui/config/readConfig/PageKeySheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
#	app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
#	app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
#	app/src/main/java/io/legado/app/ui/main/MainActivity.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
#	app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
#	app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
#	app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt
#	app/src/main/java/io/legado/app/ui/replace/edit/ReplaceEditScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/favorites/RssFavoritesScreen.kt
#	app/src/main/java/io/legado/app/ui/rss/subscription/RuleSubScreen.kt
#	app/src/main/java/io/legado/app/ui/theme/AdaptivePadding.kt
#	app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
#	app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt
#	app/src/main/java/io/legado/app/ui/theme/ImageSeedColorExtractor.kt
#	app/src/main/java/io/legado/app/ui/theme/LegadoTheme.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/ThemeOverride.kt
#	app/src/main/java/io/legado/app/ui/theme/Typography.kt
#	app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/AppTextField.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SearchBarSection.kt
#	app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
#	app/src/main/java/io/legado/app/ui/widget/components/alert/AppAlertDialog.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallIconButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/SmallTextButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/ToggleChip.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SelectionItemCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/SettingCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
#	app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/BookshelfCover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/cover/Cover.kt
#	app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenu.kt
#	app/src/main/java/io/legado/app/ui/widget/components/menuItem/RoundDropdownMenuItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/rules/RuleListScaffold.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/settingItem/SliderSettingItem.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/DynamicTopAppBar.kt
#	app/src/main/java/io/legado/app/ui/widget/components/topbar/GlassMediumFlexibleTopAppBar.kt
#	app/src/main/res/drawable/ic_add.xml
#	app/src/main/res/drawable/ic_add_online.xml
#	app/src/main/res/drawable/ic_arrange.xml
#	app/src/main/res/drawable/ic_arrow_down.xml
#	app/src/main/res/drawable/ic_arrow_right.xml
#	app/src/main/res/drawable/ic_cfg_about.xml
#	app/src/main/res/drawable/ic_cfg_backup.xml
#	app/src/main/res/drawable/ic_cfg_other.xml
#	app/src/main/res/drawable/ic_cfg_replace.xml
#	app/src/main/res/drawable/ic_cfg_source.xml
#	app/src/main/res/drawable/ic_cfg_theme.xml
#	app/src/main/res/drawable/ic_cfg_web.xml
#	app/src/main/res/drawable/ic_groups.xml
#	app/src/main/res/drawable/ic_help.xml
#	app/src/main/res/drawable/ic_more.xml
#	app/src/main/res/drawable/ic_search.xml
#	app/src/main/res/drawable/ic_view_quilt.xml
#	app/src/main/res/layout/dialog_tip_config.xml
#	app/src/main/res/layout/view_preference.xml
#	app/src/main/res/layout/view_preference_category.xml
#	app/src/main/res/values-zh-rCN/strings.xml
#	app/src/main/res/values-zh-rHK/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	app/src/main/res/values/arrays.xml
#	app/src/main/res/values/strings.xml
#	app/src/main/res/xml/pref_main.xml
#	avd.sh
#	gradle.properties
#	gradle/libs.versions.toml
#	gradle/wrapper/gradle-wrapper.properties
#	gradlew
#	modules/web/index.html

* 实现播放音频显示歌词,但是布局需要调整

* 优化音频显示布局

* 合并代码

* 合并主题色设置,新增主题保存

* 修改一下颜色设置

* 边框bug修复

* 悬浮胶囊主题颜色修复

* 给发现和订阅项增加边框

* 内置浏览器设置非全屏

* 实现正则表达式匹配字段高亮

* 增加自定义正则表达式匹配颜色

* 正则表达式配置持久化保存

* 更新下载路径

* 修复png透明图片

* 修改当前版本号

* 同步lyc版本订阅源功能

* 同步lyc版本订阅源功能

* 合并后修复

* 书架样式调整,但有bug

* 合并后提交

* bug修复

* 订阅源日志修复

* 合并后修复

* 正文标题可以自定义字体

* 列表模式可以显示更多内容

* 实现自定义标签

* 主题背景和封面背景支持动图

* 自定义标签颜色

* 自定义标签颜色

* 主题保存bug修复

* 实现个性化主题设置的备份与恢复

* 页眉页脚可自定义字号,颜色,字体

* 边距设置

* bug修复

* 主题应用bug修复

* 虚线延展

* 模糊效果设置

* 字体页面新增搜索

* 描述性文字添加

* 列表简介内容显示

* 主题导入bug修复

* 标题可以自定义字体颜色

* 匹配自定义正则表达式规则文字的字体和颜色

* 匹配自定义正则表达式规则文字的字体和颜色bug修复

* 自定义底栏图标

* 自定义底栏图标bug修复

* 实现主题设置统一管理

* 实现主题设置统一管理界面美化

* 文件夹列表模式显示美化

* 底栏bug修复

* 底栏bug修复

* 底栏液化玻璃参数提取

* 主题管理删除当前阅读背景保存

* 主题管理bug修复

* 使用自定义封面时不动态取色

* 规范性更改

* 正确的数据库迁移schema

* 移动设置项

* 书架优化

* 书架优化

* 删除 EmptyMessageView.kt — 已有 EmptyMessage 完全覆盖且功能更全,该文件为无效重复

EmptyMessageView 与 EmptyMessage 实现了完全相同的空状态 UI(Kaomoji 动画、加载指示器、消息文字),但 EmptyMessageView 缺少 buttonText/buttonImageVector/onButtonClick 参数,无任何调用者。

* 消除重复代码:提取共享的 loadFontFiles、JsonObject 扩展函数,删除无效重复的 EmptyMessageView 和 BookGroupUi 常量

- 删除 EmptyMessageView.kt(已有 EmptyMessage 完全覆盖,零调用者)
- 提取 loadFontFiles 到 help/FontLoader.kt(消除 ThemeConfigScreen 和 FontSelectScreen 间的重复)
- 提取 JsonObject.getString/getInt/getFloat/getBoolean 到 utils/JsonExtensions.kt(消除 3 个配置类中的三份相同实现)
- 移除 BookGroupUi 中与 BookGroup 实体重复的 12 个 Id* 常量,改用直接引用

* 拆分大文件:提取 ThemePackSerializer 和 ThemeAppearanceJson 到独立文件

- 提取 ThemePackSerializer 到 help/config/ThemePackSerializer.kt(106 行),从 ThemePackConfig.kt 移除
- 提取 ThemeAppearanceJson 序列化对象到 help/config/ThemeAppearanceJson.kt(140 行),从 ThemeAppearanceConfig.kt 移除
- ThemePackConfig 从 440 行减少到约 325 行,ThemeAppearanceConfig 从 267 行减少到约 117 行

BookshelfViewModel 的拆分留待后续——涉及状态管理 refactor 需要更深入的并发模型理解。

* 提取共用模式:添加 SplicedColumnDivider 和 secondaryColorOr 共用函数

- SplicedColumnGroup.kt 新增 SplicedColumnDivider() composable,消除 5 个 SettingItem 文件中各 7 行重复的 divider 前导代码
- GlassDefaults.kt 新增 secondaryColorOr() composable,统一处理深度个性化 secondaryThemeColor 覆盖逻辑(该模式曾出现 12 次)

TypefaceLoader 提取跳过——三个调用点(PageView/TextColumn/ChapterProvider)在缓存策略和系统字体回退上存在差异,需更仔细的并发分析。

* 修复架构问题:移动 RegexColorRule 和 TagColorGenerator 到正确包,提取 Haze containerColor

- 将 RegexColorRule 从 ui/book/read/config 移至 help/config,消除 ReadBookConfig 对 UI 层的反向依赖
- 将 TagColorGenerator 从 help 移至 help/config,消除对 ui.config.themeConfig 的跨层级依赖
- HazeStyle 中 3 处重复的 containerColor 解析改用 GlassDefaults.secondaryColorOr

* 书架优化

* 书架优化

* 书架优化

---------

Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
HlllllH 6 mesi fa
parent
commit
64f7de8120
100 ha cambiato i file con 8677 aggiunte e 1388 eliminazioni
  1. 31 1
      .claude/settings.local.json
  2. 3 0
      CLAUDE.md
  3. 1 0
      app/build.gradle.kts
  4. 2154 0
      app/schemas/io.legado.app.data.AppDatabase/86.json
  5. 33 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  6. 3 2
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  7. 312 15
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  8. 29 0
      app/src/main/java/io/legado/app/data/entities/RssSource.kt
  9. 35 0
      app/src/main/java/io/legado/app/help/FontLoader.kt
  10. 2 2
      app/src/main/java/io/legado/app/help/JsExtensions.kt
  11. 144 0
      app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt
  12. 94 6
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  13. 9 3
      app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt
  14. 8 0
      app/src/main/java/io/legado/app/help/config/RegexColorRule.kt
  15. 33 0
      app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt
  16. 109 0
      app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt
  17. 159 0
      app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt
  18. 327 0
      app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt
  19. 83 0
      app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt
  20. 4 0
      app/src/main/java/io/legado/app/help/storage/Backup.kt
  21. 6 7
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  22. 11 0
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  23. 6 6
      app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt
  24. 198 126
      app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt
  25. 19 2
      app/src/main/java/io/legado/app/model/AudioPlay.kt
  26. 84 0
      app/src/main/java/io/legado/app/model/Debug.kt
  27. 8 2
      app/src/main/java/io/legado/app/model/rss/Rss.kt
  28. 9 2
      app/src/main/java/io/legado/app/model/webBook/BookContent.kt
  29. 51 50
      app/src/main/java/io/legado/app/ui/about/AboutScreen.kt
  30. 1 1
      app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt
  31. 81 1
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  32. 20 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  33. 14 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  34. 119 0
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  35. 8 0
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt
  36. 32 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  37. 4 0
      app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt
  38. 22 1
      app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt
  39. 57 12
      app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt
  40. 204 0
      app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt
  41. 40 2
      app/src/main/java/io/legado/app/ui/book/read/config/TipConfigDialog.kt
  42. 7 0
      app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt
  43. 62 36
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  44. 15 1
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt
  45. 7 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextPage.kt
  46. 64 13
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  47. 30 11
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt
  48. 115 25
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  49. 2 0
      app/src/main/java/io/legado/app/ui/book/source/edit/BookSourceEditActivity.kt
  50. 4 0
      app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt
  51. 10 0
      app/src/main/java/io/legado/app/ui/config/bookshelfConfig/BookshelfConfig.kt
  52. 3 1
      app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt
  53. 391 0
      app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt
  54. 4 0
      app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt
  55. 185 0
      app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt
  56. 5 1
      app/src/main/java/io/legado/app/ui/config/themeConfig/BackgroundImageManageSheet.kt
  57. 153 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt
  58. 148 0
      app/src/main/java/io/legado/app/ui/config/themeConfig/NavIconManageSheet.kt
  59. 82 3
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt
  60. 511 367
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt
  61. 0 161
      app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt
  62. 372 0
      app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt
  63. 68 13
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  64. 197 129
      app/src/main/java/io/legado/app/ui/main/MainScreen.kt
  65. 25 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt
  66. 144 16
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  67. 3 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookShelfItem.kt
  68. 45 0
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfConfigSheet.kt
  69. 19 32
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt
  70. 8 4
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt
  71. 93 110
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt
  72. 24 1
      app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt
  73. 34 7
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt
  74. 8 5
      app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt
  75. 274 0
      app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt
  76. 12 2
      app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt
  77. 65 9
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt
  78. 9 0
      app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt
  79. 63 2
      app/src/main/java/io/legado/app/ui/rss/source/edit/RssSourceEditActivity.kt
  80. 2 1
      app/src/main/java/io/legado/app/ui/theme/AppBackground.kt
  81. 338 41
      app/src/main/java/io/legado/app/ui/theme/AppTheme.kt
  82. 29 8
      app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt
  83. 18 4
      app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt
  84. 13 27
      app/src/main/java/io/legado/app/ui/theme/Typography.kt
  85. 20 5
      app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt
  86. 1 1
      app/src/main/java/io/legado/app/ui/widget/SimpleCounterView.kt
  87. 66 11
      app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt
  88. 99 49
      app/src/main/java/io/legado/app/ui/widget/components/AppScaffold.kt
  89. 19 8
      app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt
  90. 9 1
      app/src/main/java/io/legado/app/ui/widget/components/GlassDefaults.kt
  91. 96 22
      app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt
  92. 21 1
      app/src/main/java/io/legado/app/ui/widget/components/card/GlassCard.kt
  93. 5 2
      app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt
  94. 45 0
      app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt
  95. 17 2
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt
  96. 7 2
      app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindMultiTypeItem.kt
  97. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/icon/AppIcons.kt
  98. 3 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt
  99. 38 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt
  100. 2 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt

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

@@ -14,7 +14,37 @@
       "Bash(cp -r \"D:/AndroidPrj/legado-with-MD3/.codex/skills/legado-compose-review/references\" \"D:/AndroidPrj/legado-with-MD3/.claude/skills/legado-compose-review/\")",
       "WebSearch",
       "Bash(./gradlew assembleAppDebug)",
-      "Bash(git checkout *)"
+      "Bash(git checkout *)",
+      "Bash(./gradlew :app:compileAppDebugKotlin --no-daemon)",
+      "Bash(Select-Object -Last 50)",
+      "PowerShell(./gradlew :app:compileAppDebugKotlin --no-daemon 2>&1 | Select-Object -Last 50)",
+      "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 30)",
+      "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 40)",
+      "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 15)",
+      "PowerShell(./gradlew :app:compileAppDebugKotlin --rerun-tasks 2>&1 | Select-Object -Last 5)",
+      "Bash(./gradlew assembleAppNoR8)",
+      "Bash(.\\\\gradlew.bat :app:compileAppDebugKotlin)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 15)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 10)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 8)",
+      "Bash(Get-ChildItem \"app/src/main/java/io/legado/app/ui/main/bookshelf/\" -Name)",
+      "Bash(Sort-Object)",
+      "Bash(git rm *)",
+      "Bash(git commit *)",
+      "Bash(Select-Object -Last 30)",
+      "Bash(./gradlew.bat :app:compileAppDebugKotlin)",
+      "Bash(git add *)",
+      "Bash(git mv *)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin 2>&1)",
+      "WebFetch(domain:cs.android.com)",
+      "WebFetch(domain:github.com)",
+      "PowerShell(Get-ChildItem *)",
+      "PowerShell(Get-ChildItem -Path \"D:\\\\Android\\\\.gradle\\\\caches\\\\modules-2\\\\files-2.1\\\\androidx.navigation3\\\\navigation3-runtime-android\\\\1.0.1\" -Recurse | ForEach-Object { $_.FullName + \" \\(\" + $_.Length + \" bytes\\)\" })",
+      "Bash(jar tf *)",
+      "Read(//d/tmp/**)",
+      "Bash(jar xf *)",
+      "PowerShell(.\\\\gradlew.bat :app:compileAppDebugKotlin)"
     ]
   }
 }

+ 3 - 0
CLAUDE.md

@@ -40,6 +40,9 @@ Transform tasks into verifiable goals:
 ## Build / Test / Run
 
 ```bash
+# Quick compile check (Kotlin only, no dex/package — fastest for verifying code compiles)
+.\gradlew.bat :app:compileAppDebugKotlin
+
 # Assemble all variants
 ./gradlew assembleAppRelease
 

+ 1 - 0
app/build.gradle.kts

@@ -278,5 +278,6 @@ dependencies {
     implementation(libs.miuix.shapes.android)
     implementation(libs.capsule)
     implementation(libs.backdrop)
+    implementation(libs.lyricViewx)
     implementation(libs.timber)
 }

+ 2154 - 0
app/schemas/io.legado.app.data.AppDatabase/86.json

@@ -0,0 +1,2154 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 86,
+    "identityHash": "3796a7cd8db90f34502bed88600a006f",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "rule",
+            "columnName": "rule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3796a7cd8db90f34502bed88600a006f')"
+    ]
+  }
+}

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

@@ -4,6 +4,7 @@ package io.legado.app.constant
 object PreferKey {
     const val language = "language"
     const val fontScale = "fontScale"
+    const val appFontPath = "appFontPath"
     const val appTheme = "app_theme"
     const val themeMode = "themeMode"
     const val userAgent = "userAgent"
@@ -101,6 +102,7 @@ object PreferKey {
     const val replaceEnableDefault = "replaceEnableDefault"
     const val showBrightnessView = "showBrightnessView"
     const val useUnderline = "useUnderline"
+    const val regexColorRules = "regexColorRules"
     const val adaptSpecialStyle = "adaptSpecialStyle"
     const val autoClearExpired = "autoClearExpired"
     const val autoChangeSource = "autoChangeSource"
@@ -193,9 +195,32 @@ object PreferKey {
     const val bottomBarOpacity = "bottomBarOpacity"
     const val enableBlur = "enableBlur"
     const val enableProgressiveBlur = "enableProgressiveBlur"
+    const val topBarBlurRadius = "topBarBlurRadius"
+    const val bottomBarBlurRadius = "bottomBarBlurRadius"
+    const val topBarBlurAlpha = "topBarBlurAlpha"
+    const val bottomBarBlurAlpha = "bottomBarBlurAlpha"
+    const val bottomBarLensRadius = "bottomBarLensRadius"
     const val useFlexibleTopAppBar = "useFlexibleTopAppBar"
     const val cBackground = "colorBackground"
     const val cBBackground = "colorBottomBackground"
+    const val enableDeepPersonalization = "enableDeepPersonalization"
+    const val themeColor = "colorMD3Primary"
+    const val secondaryThemeColor = "colorMD3Secondary"
+    const val primaryTextColor = "colorMD3OnSurface"
+    const val secondaryTextColor = "colorMD3OnPrimaryContainer"
+    const val themeBackgroundColor = "colorMD3Background"
+    const val labelContainerColor = "colorMD3SurfaceContainerLow"
+    const val enableContainerBorder = "enableContainerBorder"
+    const val containerBorderWidth = "containerBorderWidth"
+    const val containerBorderStyle = "containerBorderStyle"
+    const val containerBorderColor = "containerBorderColor"
+    const val containerBorderDashWidth = "containerBorderDashWidth"
+    // 中间单线间隔设置
+    const val enableItemDivider = "enableItemDivider"
+    const val itemDividerWidth = "itemDividerWidth"
+    const val itemDividerLength = "itemDividerLength"
+    const val itemDividerColor = "itemDividerColor"
+    const val bookInfoInputColor = "colorBookInfoInput"
     const val bgImage = "backgroundImage"
     const val bgImageBlurring = "backgroundImageBlurring"
     const val cNPrimary = "colorPrimaryNight"
@@ -274,8 +299,16 @@ object PreferKey {
 
     const val showTip = "showTip"
     const val showBookCount = "showBookCount"
+    const val showBookIntro = "showBookIntro"
+    const val bookshelfIntroMaxLines = "bookshelfIntroMaxLines"
     const val sliderVibrator = "sliderVibrator"
     const val selectVibrator = "selectVibrator"
     const val audioCacheCleanTime = "audioCacheCleanTime"
     const val audioPreDownloadNum = "audioPreDownloadNum"
+    const val customTagColors = "customTagColors"
+    const val enableCustomTagColors = "enableCustomTagColors"
+    const val navIconBookshelf = "navIconBookshelf"
+    const val navIconExplore = "navIconExplore"
+    const val navIconRss = "navIconRss"
+    const val navIconMy = "navIconMy"
 }

+ 3 - 2
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -69,7 +69,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 85,
+    version = 86,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -121,7 +121,8 @@ val appDb by lazy {
         AutoMigration(from = 81, to = 82),
         AutoMigration(from = 82, to = 83),
         AutoMigration(from = 83, to = 84),
-        AutoMigration(from = 84, to = 85)
+        AutoMigration(from = 84, to = 85),
+        AutoMigration(from = 85, to = 86)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 312 - 15
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -17,6 +17,11 @@ import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 
+data class GroupBookCount(
+    val groupId: Long,
+    val count: Int
+)
+
 @Dao
 interface BookDao {
 
@@ -91,7 +96,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         where type & ${BookType.text} > 0
         and type & ${BookType.local} = 0
@@ -125,7 +132,9 @@ interface BookDao {
         type,
         `group`,
         `order`,
-        canUpdate
+        canUpdate,
+        ifnull(customIntro, intro) as intro,
+        kind
     FROM books
     ORDER BY durChapterTime DESC
 """
@@ -156,8 +165,10 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
-        FROM books 
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
+        FROM books
         WHERE type & ${BookType.audio} > 0
         """
     )
@@ -187,7 +198,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE type & ${BookType.local} > 0
         """
@@ -223,7 +236,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
         and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -260,7 +275,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         where type & ${BookType.local} > 0
         and ((SELECT sum(groupId) FROM book_groups where groupId > 0) & `group`) = 0
@@ -292,7 +309,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE (`group` & :group) > 0
         """
@@ -325,7 +344,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%'
         """
@@ -356,7 +377,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         where type & ${BookType.updateError} > 0 
         order by durChapterTime desc
@@ -388,7 +411,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE durChapterIndex = 0 AND durChapterPos = 0
         """
@@ -419,7 +444,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
         """
@@ -450,7 +477,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
         """
@@ -481,7 +510,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE type & ${BookType.image} > 0
         """
@@ -512,7 +543,9 @@ interface BookDao {
             type,
             `group`,
             `order`,
-            canUpdate
+            canUpdate,
+            ifnull(customIntro, intro) as intro,
+            kind
         FROM books 
         WHERE type & ${BookType.text} > 0
         """
@@ -627,4 +660,268 @@ interface BookDao {
 
     @Query("delete from books where type & ${BookType.notShelf} > 0")
     fun deleteNotShelfBook()
+
+    // ── Group preview / count queries (DB-level, replaces in-memory buildGroupPreviewState) ──
+
+    @Query("SELECT COUNT(*) FROM books")
+    fun flowAllBookShelfCount(): Flow<Int>
+
+    @Query(
+        """
+        SELECT ${BookGroup.IdAll} AS groupId, COUNT(*) AS count FROM books
+        UNION ALL SELECT ${BookGroup.IdRoot}, COUNT(*) FROM books
+            WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+            AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1
+        UNION ALL SELECT ${BookGroup.IdLocal}, COUNT(*) FROM books WHERE type & ${BookType.local} > 0
+        UNION ALL SELECT ${BookGroup.IdAudio}, COUNT(*) FROM books WHERE type & ${BookType.audio} > 0
+        UNION ALL SELECT ${BookGroup.IdNetNone}, COUNT(*) FROM books
+            WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+        UNION ALL SELECT ${BookGroup.IdLocalNone}, COUNT(*) FROM books
+            WHERE type & ${BookType.local} > 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+        UNION ALL SELECT ${BookGroup.IdManga}, COUNT(*) FROM books WHERE type & ${BookType.image} > 0
+        UNION ALL SELECT ${BookGroup.IdText}, COUNT(*) FROM books WHERE type & ${BookType.text} > 0
+        UNION ALL SELECT ${BookGroup.IdError}, COUNT(*) FROM books WHERE type & ${BookType.updateError} > 0
+        UNION ALL SELECT ${BookGroup.IdUnread}, COUNT(*) FROM books WHERE durChapterIndex = 0 AND durChapterPos = 0
+        UNION ALL SELECT ${BookGroup.IdReading}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
+        UNION ALL SELECT ${BookGroup.IdReadFinished}, COUNT(*) FROM books WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
+        """
+    )
+    fun flowSystemGroupCounts(): Flow<List<GroupBookCount>>
+
+    @Query("SELECT COUNT(*) FROM books WHERE (`group` & :groupId) > 0")
+    fun flowUserGroupBookCount(groupId: Long): Flow<Int>
+
+    fun flowGroupPreview(groupId: Long): Flow<List<BookShelfItem>> {
+        return when (groupId) {
+            BookGroup.IdRoot -> flowBookShelfRootPreview()
+            BookGroup.IdAll -> flowBookShelfPreview()
+            BookGroup.IdLocal -> flowBookShelfLocalPreview()
+            BookGroup.IdAudio -> flowBookShelfAudioPreview()
+            BookGroup.IdNetNone -> flowBookShelfNetNoGroupPreview()
+            BookGroup.IdLocalNone -> flowBookShelfLocalNoGroupPreview()
+            BookGroup.IdManga -> flowBookShelfMangaPreview()
+            BookGroup.IdText -> flowBookShelfTextPreview()
+            BookGroup.IdError -> flowBookShelfUpdateErrorPreview()
+            BookGroup.IdUnread -> flowBookShelfUnreadPreview()
+            BookGroup.IdReading -> flowBookShelfReadingPreview()
+            BookGroup.IdReadFinished -> flowBookShelfReadFinishedPreview()
+            else -> flowBookShelfPreviewByUserGroup(groupId)
+        }.map { list ->
+            list.filterNot { it.isNotShelf }
+        }
+    }
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+            AND (SELECT show FROM book_groups WHERE groupId = ${BookGroup.IdNetNone}) != 1
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfRootPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.local} > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfLocalPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.audio} > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfAudioPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfNetNoGroupPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.local} > 0
+            AND ((SELECT COALESCE(SUM(groupId), 0) FROM book_groups WHERE groupId > 0) & `group`) = 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfLocalNoGroupPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.image} > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfMangaPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.text} > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfTextPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE type & ${BookType.updateError} > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfUpdateErrorPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE durChapterIndex = 0 AND durChapterPos = 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfUnreadPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfReadingPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfReadFinishedPreview(): Flow<List<BookShelfItem>>
+
+    @Query(
+        """
+        SELECT bookUrl, name, author, origin, originName,
+            coverUrl, customCoverUrl, durChapterTitle, durChapterTime,
+            durChapterPos, latestChapterTitle, latestChapterTime,
+            lastCheckCount, totalChapterNum, durChapterIndex,
+            type, `group`, `order`, canUpdate,
+            ifnull(customIntro, intro) as intro, kind
+        FROM books
+        WHERE (`group` & :groupId) > 0
+        ORDER BY CASE WHEN (coverUrl IS NOT NULL OR customCoverUrl IS NOT NULL) THEN 0 ELSE 1 END, durChapterTime DESC
+        LIMIT 4
+        """
+    )
+    fun flowBookShelfPreviewByUserGroup(groupId: Long): Flow<List<BookShelfItem>>
 }

+ 29 - 0
app/src/main/java/io/legado/app/data/entities/RssSource.kt

@@ -86,12 +86,32 @@ data class RssSource(
     var loadWithBaseUrl: Boolean = true,
     /**注入js**/
     var injectJs: String? = null,
+    /**提前预注入js**/
+    var preloadJs: String? = null,
+    /**web形式起始页**/
+    var startHtml: String? = null,
+    var startStyle: String? = null,
+    var startJs: String? = null,
+    /**是否输出web网页日志**/
+    @ColumnInfo(defaultValue = "0")
+    var showWebLog: Boolean = false,
     /*其它规则*/
     /**最后更新时间,用于排序**/
     @ColumnInfo(defaultValue = "0")
     var lastUpdateTime: Long = 0,
     @ColumnInfo(defaultValue = "0")
     var customOrder: Int = 0,
+    /**类型 0网页,1图片,2视频**/
+    @ColumnInfo(defaultValue = "0")
+    var type: Int = 0,
+    /**是否启用预加载**/
+    @ColumnInfo(defaultValue = "0")
+    var preload: Boolean = false,
+    /**是否优先加载缓存**/
+    @ColumnInfo(defaultValue = "0")
+    var cacheFirst: Boolean = false,
+    /**搜索url**/
+    var searchUrl: String? = null,
     //用户控制的重定向
     @ColumnInfo(defaultValue = "ASK_CROSS_ORIGIN")
     var redirectPolicy: String = "ASK_CROSS_ORIGIN"
@@ -143,6 +163,15 @@ data class RssSource(
                 && equal(variableComment, source.variableComment)
                 && equal(style, source.style)
                 && equal(injectJs, source.injectJs)
+                && equal(preloadJs, source.preloadJs)
+                && equal(startHtml, source.startHtml)
+                && equal(startStyle, source.startStyle)
+                && equal(startJs, source.startJs)
+                && showWebLog == source.showWebLog
+                && type == source.type
+                && preload == source.preload
+                && cacheFirst == source.cacheFirst
+                && equal(searchUrl, source.searchUrl)
     }
 
     private fun equal(a: String?, b: String?): Boolean {

+ 35 - 0
app/src/main/java/io/legado/app/help/FontLoader.kt

@@ -0,0 +1,35 @@
+package io.legado.app.help
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import io.legado.app.utils.FileDoc
+import io.legado.app.utils.listFileDocs
+import java.io.File
+
+fun loadFontFiles(context: Context, folderUri: Uri?): List<FileDoc> {
+    val fontRegex = Regex("(?i).*\\.[ot]tf")
+    if (folderUri != null) {
+        val documentFile = DocumentFile.fromTreeUri(context, folderUri)
+        return documentFile?.let { docFile ->
+            val fontFiles = mutableListOf<FileDoc>()
+            docFile.listFiles()?.forEach { file ->
+                if (file.isFile && file.name?.matches(fontRegex) == true) {
+                    fontFiles.add(
+                        FileDoc(
+                            name = file.name ?: "",
+                            isDir = false,
+                            size = file.length(),
+                            lastModified = file.lastModified(),
+                            uri = file.uri
+                        )
+                    )
+                }
+            }
+            fontFiles
+        } ?: emptyList()
+    } else {
+        val fontPath = "${context.getExternalFilesDir(null)?.absolutePath}/font"
+        return File(fontPath).listFileDocs { it.name.matches(fontRegex) }
+    }
+}

+ 2 - 2
app/src/main/java/io/legado/app/help/JsExtensions.kt

@@ -1105,7 +1105,7 @@ interface JsExtensions : JsEncodeUtils {
      * 弹窗提示
      */
     fun toast(msg: Any?) {
-        rhinoContext.ensureActive()
+        rhinoContextOrNull?.ensureActive()
         appCtx.toastForJs("${getSource()?.getTag()}: ${msg.toString()}")
     }
 
@@ -1113,7 +1113,7 @@ interface JsExtensions : JsEncodeUtils {
      * 弹窗提示 停留时间较长
      */
     fun longToast(msg: Any?) {
-        rhinoContext.ensureActive()
+        rhinoContextOrNull?.ensureActive()
         appCtx.longToastForJs("${getSource()?.getTag()}: ${msg.toString()}")
     }
 

+ 144 - 0
app/src/main/java/io/legado/app/help/config/PersonalizationThemeConfig.kt

@@ -0,0 +1,144 @@
+package io.legado.app.help.config
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.getString
+import splitties.init.appCtx
+import java.io.File
+import java.lang.reflect.Type
+
+object PersonalizationThemeConfig {
+    const val configFileName = "personalizationThemeConfig.json"
+    val configFilePath = FileUtils.getPath(appCtx.filesDir, configFileName)
+
+    private val THEME_GSON: Gson by lazy {
+        GsonBuilder()
+            .registerTypeAdapter(Config::class.java, ConfigSerializer())
+            .setPrettyPrinting()
+            .disableHtmlEscaping()
+            .create()
+    }
+
+    private val _configList: ArrayList<Config> by lazy {
+        ArrayList(getConfigs() ?: emptyList())
+    }
+
+    val configList: List<Config> get() = _configList
+
+    fun save() {
+        val json = THEME_GSON.toJson(_configList)
+        FileUtils.delete(configFilePath)
+        FileUtils.createFileIfNotExist(configFilePath).writeText(json)
+    }
+
+    fun toJson(config: Config): String {
+        return THEME_GSON.toJson(config)
+    }
+
+    fun toJson(): String {
+        return THEME_GSON.toJson(_configList)
+    }
+
+    fun fromJson(json: String): Boolean {
+        return kotlin.runCatching {
+            val configs = THEME_GSON.fromJson(json, Array<Config>::class.java)
+            _configList.clear()
+            _configList.addAll(configs)
+            save()
+            true
+        }.getOrDefault(false)
+    }
+
+    fun delConfig(index: Int) {
+        if (index in _configList.indices) {
+            _configList.removeAt(index)
+            save()
+        }
+    }
+
+    fun addConfig(json: String): Boolean {
+        return kotlin.runCatching {
+            val config = THEME_GSON.fromJson(json, Config::class.java)
+            addConfig(config)
+            true
+        }.getOrDefault(false)
+    }
+
+    fun addConfig(newConfig: Config) {
+        _configList.forEachIndexed { index, config ->
+            if (newConfig.themeName == config.themeName) {
+                _configList[index] = newConfig
+                save()
+                return
+            }
+        }
+        _configList.add(newConfig)
+        save()
+    }
+
+    private fun getConfigs(): List<Config>? {
+        val configFile = File(configFilePath)
+        if (configFile.exists()) {
+            kotlin.runCatching {
+                val json = configFile.readText()
+                return THEME_GSON.fromJson(json, Array<Config>::class.java).toList()
+            }
+        }
+        return null
+    }
+
+    fun applyConfig(config: Config) {
+        config.appearance.applyToThemeConfig()
+    }
+
+    fun savePersonalizationTheme(name: String): Config {
+        val config = Config(
+            themeName = name,
+            appearance = ThemeAppearanceConfig.fromCurrent()
+        )
+        addConfig(config)
+        return config
+    }
+
+    data class Config(
+        var themeName: String = "",
+        var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig()
+    ) {
+        val themeColor: Int get() = appearance.themeColor
+    }
+}
+
+class ConfigSerializer : JsonSerializer<PersonalizationThemeConfig.Config>,
+    JsonDeserializer<PersonalizationThemeConfig.Config> {
+
+    override fun serialize(
+        src: PersonalizationThemeConfig.Config,
+        typeOfSrc: Type,
+        context: JsonSerializationContext
+    ): JsonElement {
+        val obj = JsonObject()
+        obj.addProperty("themeName", src.themeName)
+        ThemeAppearanceJson.writeTo(obj, src.appearance)
+        return obj
+    }
+
+    override fun deserialize(
+        json: JsonElement,
+        typeOfT: Type,
+        context: JsonDeserializationContext
+    ): PersonalizationThemeConfig.Config {
+        val obj = json.asJsonObject
+        return PersonalizationThemeConfig.Config(
+            themeName = obj.getString("themeName", "") ?: "",
+            appearance = ThemeAppearanceJson.readFrom(obj)
+        )
+    }
+
+}

+ 94 - 6
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -26,10 +26,12 @@ import io.legado.app.utils.getFile
 import io.legado.app.utils.getMeanColor
 import io.legado.app.utils.getPrefBoolean
 import io.legado.app.utils.getPrefInt
+import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hexString
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.putPrefBoolean
 import io.legado.app.utils.putPrefInt
+import io.legado.app.utils.putPrefString
 import io.legado.app.utils.resizeAndRecycle
 import splitties.init.appCtx
 import java.io.File
@@ -226,6 +228,12 @@ object ReadBookConfig {
                 appCtx.putPrefBoolean(PreferKey.shareLayout, value)
             }
         }
+    
+    val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
+
+    fun saveRegexColorRules() {
+        save()
+    }
 
     /**
      * 两端对齐
@@ -260,6 +268,36 @@ object ReadBookConfig {
             config.textFont = value
         }
 
+    var titleFont: String
+        get() = config.titleFont
+        set(value) {
+            config.titleFont = value
+        }
+
+    var headerFont: String
+        get() = config.headerFont
+        set(value) {
+            config.headerFont = value
+        }
+
+    var footerFont: String
+        get() = config.footerFont
+        set(value) {
+            config.footerFont = value
+        }
+
+    var headerFontSize: Int
+        get() = config.headerFontSize.takeIf { it > 0 } ?: 12
+        set(value) {
+            config.headerFontSize = value
+        }
+
+    var footerFontSize: Int
+        get() = config.footerFontSize.takeIf { it > 0 } ?: 12
+        set(value) {
+            config.footerFontSize = value
+        }
+
     var textBold: Int
         get() = config.textBold
         set(value) {
@@ -393,6 +431,12 @@ object ReadBookConfig {
             config.titleBottomSpacing = value
         }
 
+    var titleColor: Int
+        get() = config.titleColor
+        set(value) {
+            config.titleColor = value
+        }
+
     var paragraphIndent: String
         get() = config.paragraphIndent
         set(value) {
@@ -417,6 +461,12 @@ object ReadBookConfig {
             config.underlinePadding = value
         }
 
+    var underlineExtend: Boolean
+        get() = config.underlineExtend
+        set(value) {
+            config.underlineExtend = value
+        }
+
     var dottedLine: Boolean
         get() = config.dottedLine
         set(value) {
@@ -544,9 +594,14 @@ object ReadBookConfig {
         }
 
     fun getExportConfig(): Config {
-        val exportConfig = durConfig.copy()
+        val exportConfig = durConfig.copy(regexColorRules = ArrayList(durConfig.regexColorRules.map { it.copy() }))
         if (shareLayout) {
             exportConfig.textFont = shareConfig.textFont
+            exportConfig.titleFont = shareConfig.titleFont
+            exportConfig.headerFont = shareConfig.headerFont
+            exportConfig.footerFont = shareConfig.footerFont
+            exportConfig.headerFontSize = shareConfig.headerFontSize
+            exportConfig.footerFontSize = shareConfig.footerFontSize
             exportConfig.textBold = shareConfig.textBold
             exportConfig.textSize = shareConfig.textSize
             exportConfig.letterSpacing = shareConfig.letterSpacing
@@ -556,6 +611,7 @@ object ReadBookConfig {
             exportConfig.titleSize = shareConfig.titleSize
             exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
             exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
+            exportConfig.titleColor = shareConfig.titleColor
             exportConfig.paddingBottom = shareConfig.paddingBottom
             exportConfig.paddingLeft = shareConfig.paddingLeft
             exportConfig.paddingRight = shareConfig.paddingRight
@@ -576,7 +632,8 @@ object ReadBookConfig {
             exportConfig.tipFooterLeft = shareConfig.tipFooterLeft
             exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
             exportConfig.tipFooterRight = shareConfig.tipFooterRight
-            exportConfig.tipColor = shareConfig.tipColor
+            exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
+            exportConfig.tipFooterColor = shareConfig.tipFooterColor
             exportConfig.headerMode = shareConfig.headerMode
             // MD3专有属性
             exportConfig.footerMode = shareConfig.footerMode
@@ -628,6 +685,20 @@ object ReadBookConfig {
                 config.textFont = ""
             }
         }
+        if (config.titleFont.isNotEmpty()) {
+            val fontName = config.titleFont
+            val fontPath =
+                FileUtils.getPath(appCtx.externalFiles, "font", fontName)
+            val fontFile = configDir.getFile(fontName)
+            if (fontFile.exists()) {
+                if (!FileUtils.exist(fontPath)) {
+                    fontFile.copyTo(File(fontPath))
+                }
+                config.titleFont = fontPath
+            } else {
+                config.titleFont = ""
+            }
+        }
         if (config.bgType == 2) {
             val bgName = FileUtils.getName(config.bgStr)
             config.bgStr = bgName
@@ -688,6 +759,11 @@ object ReadBookConfig {
         private var pageAnim: Int = 0,//翻页动画
         private var pageAnimEInk: Int = 4,
         var textFont: String = "",//字体
+        var titleFont: String = "",//标题字体
+        var headerFont: String = "",//页眉字体
+        var footerFont: String = "",//页脚字体
+        var headerFontSize: Int = 12,//页眉字号
+        var footerFontSize: Int = 12,//页脚字号
         var textBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var textSize: Int = 20,//文字大小
         var textItalic: Boolean = false,// 是否启用斜体
@@ -704,6 +780,7 @@ object ReadBookConfig {
         var titleSize: Int = 0,
         var titleTopSpacing: Int = 0,
         var titleBottomSpacing: Int = 0,
+        var titleColor: Int = 0,
         var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var titleLineSpacingExtra: Int = 12,
         var titleLineSpacingSub: Int = 12,
@@ -715,6 +792,7 @@ object ReadBookConfig {
         var underline: Boolean = false, //下划线
         var underlinePadding: Int = 10,
         var underlineHeight: Int = 1,
+        var underlineExtend: Boolean = false, //下划线延伸
         var underlineColor: String = "#3E3D3B",
         var underlineColorNight: String = "#ADADAD",
         var dottedLine: Boolean = false, //虚线
@@ -740,10 +818,12 @@ object ReadBookConfig {
         var tipFooterLeft: Int = ReadTipConfig.chapterTitle,
         var tipFooterMiddle: Int = ReadTipConfig.none,
         var tipFooterRight: Int = ReadTipConfig.pageAndTotal,
-        var tipColor: Int = 0,
+        var tipHeaderColor: Int = 0,
+        var tipFooterColor: Int = 0,
         var tipDividerColor: Int = -1,
         var headerMode: Int = 0,
-        var footerMode: Int = 0
+        var footerMode: Int = 0,
+        var regexColorRules: ArrayList<RegexColorRule> = arrayListOf()
     ) {
 
         @Transient
@@ -821,6 +901,11 @@ object ReadBookConfig {
             "pageAnim" to pageAnim,
             "pageAnimEInk" to pageAnimEInk,
             "textFont" to textFont,
+            "titleFont" to titleFont,
+            "headerFont" to headerFont,
+            "footerFont" to footerFont,
+            "headerFontSize" to headerFontSize,
+            "footerFontSize" to footerFontSize,
             "textBold" to textBold,
             "textSize" to textSize,
             "letterSpacing" to letterSpacing,
@@ -830,6 +915,7 @@ object ReadBookConfig {
             "titleSize" to titleSize,
             "titleTopSpacing" to titleTopSpacing,
             "titleBottomSpacing" to titleBottomSpacing,
+            "titleColor" to titleColor,
             "paragraphIndent" to paragraphIndent,
             "paddingBottom" to paddingBottom,
             "paddingLeft" to paddingLeft,
@@ -851,10 +937,12 @@ object ReadBookConfig {
             "tipFooterLeft" to tipFooterLeft,
             "tipFooterMiddle" to tipFooterMiddle,
             "tipFooterRight" to tipFooterRight,
-            "tipColor" to tipColor,
+            "tipHeaderColor" to tipHeaderColor,
+            "tipFooterColor" to tipFooterColor,
             "tipDividerColor" to tipDividerColor,
             "headerMode" to headerMode,
-            "footerMode" to footerMode
+            "footerMode" to footerMode,
+            "regexColorRules" to regexColorRules.map { mapOf("name" to it.name, "pattern" to it.pattern, "color" to it.color, "fontPath" to it.fontPath) }
         )
 
         fun getBgPath(bgIndex: Int): String? {

+ 9 - 3
app/src/main/java/io/legado/app/help/config/ReadTipConfig.kt

@@ -85,10 +85,16 @@ object ReadTipConfig {
             ReadBookConfig.config.footerMode = value
         }
 
-    var tipColor: Int
-        get() = ReadBookConfig.config.tipColor
+    var tipHeaderColor: Int
+        get() = ReadBookConfig.config.tipHeaderColor
         set(value) {
-            ReadBookConfig.config.tipColor = value
+            ReadBookConfig.config.tipHeaderColor = value
+        }
+
+    var tipFooterColor: Int
+        get() = ReadBookConfig.config.tipFooterColor
+        set(value) {
+            ReadBookConfig.config.tipFooterColor = value
         }
 
     var tipDividerColor: Int

+ 8 - 0
app/src/main/java/io/legado/app/help/config/RegexColorRule.kt

@@ -0,0 +1,8 @@
+package io.legado.app.help.config
+
+data class RegexColorRule(
+    var name: String,
+    var pattern: String,
+    var color: Int,
+    var fontPath: String = ""
+)

+ 33 - 0
app/src/main/java/io/legado/app/help/config/TagColorGenerator.kt

@@ -0,0 +1,33 @@
+package io.legado.app.help.config
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.core.graphics.ColorUtils
+import io.legado.app.ui.config.themeConfig.TagColorPair
+
+object TagColorGenerator {
+
+    fun generateTagColors(baseColor: Color): List<TagColorPair> {
+        val baseHsl = FloatArray(3)
+        ColorUtils.colorToHSL(baseColor.toArgb(), baseHsl)
+        val baseHue = baseHsl[0]
+
+        val result = mutableListOf<TagColorPair>()
+
+        val hueOffsets = listOf(0, 45, 90, 135, 180, 225, 270, 315)
+
+        hueOffsets.forEach { offset ->
+            val newHue = (baseHue + offset) % 360
+
+            val textHsl = floatArrayOf(newHue, 0.75f, 0.45f)
+            val textColor = Color.hsl(textHsl[0], textHsl[1], textHsl[2]).toArgb()
+
+            val bgHsl = floatArrayOf(newHue, 0.35f, 0.90f)
+            val bgColor = Color.hsl(bgHsl[0], bgHsl[1], bgHsl[2]).toArgb()
+
+            result.add(TagColorPair(textColor, bgColor))
+        }
+
+        return result
+    }
+}

+ 109 - 0
app/src/main/java/io/legado/app/help/config/ThemeAppearanceConfig.kt

@@ -0,0 +1,109 @@
+package io.legado.app.help.config
+
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+
+data class ThemeAppearanceConfig(
+    var enableDeepPersonalization: Boolean = false,
+    var themeColor: Int = 0,
+    var secondaryThemeColor: Int = 0,
+    var primaryTextColor: Int = 0,
+    var secondaryTextColor: Int = 0,
+    var backgroundColor: Int = 0,
+    var labelContainerColor: Int = 0,
+    var bookInfoInputColor: Int = 0,
+    var appFontPath: String? = null,
+    var enableContainerBorder: Boolean = false,
+    var containerBorderWidth: Float = 1f,
+    var containerBorderStyle: String = "solid",
+    var containerBorderDashWidth: Float = 4f,
+    var containerBorderColor: Int = 0,
+    var enableItemDivider: Boolean = true,
+    var itemDividerWidth: Float = 1f,
+    var itemDividerLength: Float = 80f,
+    var itemDividerColor: Int = 0,
+    var enableCustomTagColors: Boolean = false,
+    var customTagColorsJson: String? = null,
+    var enableBlur: Boolean = false,
+    var topBarBlurRadius: Int = 24,
+    var bottomBarBlurRadius: Int = 8,
+    var topBarBlurAlpha: Int = 73,
+    var bottomBarBlurAlpha: Int = 40,
+    var bottomBarLensRadius: Float = 24f
+) {
+
+    val hasDeepColorOverrides: Boolean
+        get() = themeColor != 0 ||
+                secondaryThemeColor != 0 ||
+                primaryTextColor != 0 ||
+                secondaryTextColor != 0 ||
+                backgroundColor != 0 ||
+                labelContainerColor != 0
+
+    fun applyToThemeConfig(appFontPathOverride: String? = appFontPath) {
+        ThemeConfig.enableDeepPersonalization = enableDeepPersonalization
+        ThemeConfig.themeColor = themeColor
+        ThemeConfig.secondaryThemeColor = secondaryThemeColor
+        ThemeConfig.primaryTextColor = primaryTextColor
+        ThemeConfig.secondaryTextColor = secondaryTextColor
+        ThemeConfig.themeBackgroundColor = backgroundColor
+        ThemeConfig.labelContainerColor = labelContainerColor
+
+        ThemeConfig.bookInfoInputColor = bookInfoInputColor
+
+        ThemeConfig.appFontPath = appFontPathOverride
+
+        ThemeConfig.enableContainerBorder = enableContainerBorder
+        ThemeConfig.containerBorderWidth = containerBorderWidth
+        ThemeConfig.containerBorderStyle = containerBorderStyle.ifBlank { "solid" }
+        ThemeConfig.containerBorderDashWidth = containerBorderDashWidth
+        ThemeConfig.containerBorderColor = containerBorderColor
+
+        ThemeConfig.enableItemDivider = enableItemDivider
+        ThemeConfig.itemDividerWidth = itemDividerWidth
+        ThemeConfig.itemDividerLength = itemDividerLength
+        ThemeConfig.itemDividerColor = itemDividerColor
+
+        ThemeConfig.enableCustomTagColors = enableCustomTagColors
+        ThemeConfig.customTagColorsJson = ThemeAppearanceJson.convertTagColorsFromHex(customTagColorsJson)
+
+        ThemeConfig.enableBlur = enableBlur
+        ThemeConfig.topBarBlurRadius = topBarBlurRadius
+        ThemeConfig.bottomBarBlurRadius = bottomBarBlurRadius
+        ThemeConfig.topBarBlurAlpha = topBarBlurAlpha
+        ThemeConfig.bottomBarBlurAlpha = bottomBarBlurAlpha
+        ThemeConfig.bottomBarLensRadius = bottomBarLensRadius
+    }
+
+    companion object {
+        fun fromCurrent(): ThemeAppearanceConfig {
+            return ThemeAppearanceConfig(
+                enableDeepPersonalization = ThemeConfig.enableDeepPersonalization,
+                themeColor = ThemeConfig.themeColor,
+                secondaryThemeColor = ThemeConfig.secondaryThemeColor,
+                primaryTextColor = ThemeConfig.primaryTextColor,
+                secondaryTextColor = ThemeConfig.secondaryTextColor,
+                backgroundColor = ThemeConfig.themeBackgroundColor,
+                labelContainerColor = ThemeConfig.labelContainerColor,
+                bookInfoInputColor = ThemeConfig.bookInfoInputColor,
+                appFontPath = ThemeConfig.appFontPath,
+                enableContainerBorder = ThemeConfig.enableContainerBorder,
+                containerBorderWidth = ThemeConfig.containerBorderWidth,
+                containerBorderStyle = ThemeConfig.containerBorderStyle,
+                containerBorderDashWidth = ThemeConfig.containerBorderDashWidth,
+                containerBorderColor = ThemeConfig.containerBorderColor,
+                enableItemDivider = ThemeConfig.enableItemDivider,
+                itemDividerWidth = ThemeConfig.itemDividerWidth,
+                itemDividerLength = ThemeConfig.itemDividerLength,
+                itemDividerColor = ThemeConfig.itemDividerColor,
+                enableCustomTagColors = ThemeConfig.enableCustomTagColors,
+                customTagColorsJson = ThemeConfig.customTagColorsJson,
+                enableBlur = ThemeConfig.enableBlur,
+                topBarBlurRadius = ThemeConfig.topBarBlurRadius,
+                bottomBarBlurRadius = ThemeConfig.bottomBarBlurRadius,
+                topBarBlurAlpha = ThemeConfig.topBarBlurAlpha,
+                bottomBarBlurAlpha = ThemeConfig.bottomBarBlurAlpha,
+                bottomBarLensRadius = ThemeConfig.bottomBarLensRadius
+            )
+        }
+    }
+}

+ 159 - 0
app/src/main/java/io/legado/app/help/config/ThemeAppearanceJson.kt

@@ -0,0 +1,159 @@
+package io.legado.app.help.config
+
+import androidx.core.graphics.toColorInt
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.legado.app.ui.config.themeConfig.TagColorPair
+import io.legado.app.utils.GSON
+import io.legado.app.utils.getBoolean
+import io.legado.app.utils.getFloat
+import io.legado.app.utils.getString
+
+object ThemeAppearanceJson {
+
+    fun writeTo(obj: JsonObject, src: ThemeAppearanceConfig) {
+        obj.addProperty("enableDeepPersonalization", src.enableDeepPersonalization)
+        obj.addProperty("themeColor", colorToHex(src.themeColor))
+        obj.addProperty("secondaryThemeColor", colorToHex(src.secondaryThemeColor))
+        obj.addProperty("primaryTextColor", colorToHex(src.primaryTextColor))
+        obj.addProperty("secondaryTextColor", colorToHex(src.secondaryTextColor))
+        obj.addProperty("backgroundColor", colorToHex(src.backgroundColor))
+        obj.addProperty("labelContainerColor", colorToHex(src.labelContainerColor))
+        obj.addProperty("bookInfoInputColor", colorToHex(src.bookInfoInputColor))
+        obj.addProperty("appFontPath", src.appFontPath)
+        obj.addProperty("enableContainerBorder", src.enableContainerBorder)
+        obj.addProperty("containerBorderWidth", src.containerBorderWidth)
+        obj.addProperty("containerBorderStyle", src.containerBorderStyle)
+        obj.addProperty("containerBorderDashWidth", src.containerBorderDashWidth)
+        obj.addProperty("containerBorderColor", colorToHex(src.containerBorderColor))
+        obj.addProperty("enableItemDivider", src.enableItemDivider)
+        obj.addProperty("itemDividerWidth", src.itemDividerWidth)
+        obj.addProperty("itemDividerLength", src.itemDividerLength)
+        obj.addProperty("itemDividerColor", colorToHex(src.itemDividerColor))
+        obj.addProperty("enableCustomTagColors", src.enableCustomTagColors)
+        obj.addProperty("customTagColorsJson", convertTagColorsToHex(src.customTagColorsJson))
+        obj.addProperty("enableBlur", src.enableBlur)
+        obj.addProperty("topBarBlurRadius", src.topBarBlurRadius)
+        obj.addProperty("bottomBarBlurRadius", src.bottomBarBlurRadius)
+        obj.addProperty("topBarBlurAlpha", src.topBarBlurAlpha)
+        obj.addProperty("bottomBarBlurAlpha", src.bottomBarBlurAlpha)
+        obj.addProperty("bottomBarLensRadius", src.bottomBarLensRadius)
+    }
+
+    fun readFrom(obj: JsonObject): ThemeAppearanceConfig {
+        val appearance = ThemeAppearanceConfig(
+            themeColor = readColor(obj, "themeColor", "md3Primary"),
+            secondaryThemeColor = readColor(obj, "secondaryThemeColor", "md3Secondary", "md3OnPrimary"),
+            primaryTextColor = readColor(obj, "primaryTextColor", "md3OnSurface"),
+            secondaryTextColor = readColor(obj, "secondaryTextColor", "md3OnPrimaryContainer"),
+            backgroundColor = readColor(obj, "backgroundColor", "md3Background"),
+            labelContainerColor = readColor(obj, "labelContainerColor", "md3SurfaceContainerLow"),
+            bookInfoInputColor = readColor(obj, "bookInfoInputColor"),
+            appFontPath = obj.getString("appFontPath", null),
+            enableContainerBorder = obj.getBoolean("enableContainerBorder", false),
+            containerBorderWidth = obj.getFloat("containerBorderWidth", 1f),
+            containerBorderStyle = obj.getString("containerBorderStyle", "solid") ?: "solid",
+            containerBorderDashWidth = obj.getFloat("containerBorderDashWidth", 4f),
+            containerBorderColor = readColor(obj, "containerBorderColor"),
+            enableItemDivider = obj.getBoolean("enableItemDivider", true),
+            itemDividerWidth = obj.getFloat("itemDividerWidth", 1f),
+            itemDividerLength = obj.getFloat("itemDividerLength", 80f),
+            itemDividerColor = readColor(obj, "itemDividerColor"),
+            enableCustomTagColors = obj.getBoolean("enableCustomTagColors", false),
+            customTagColorsJson = obj.getString("customTagColorsJson", null),
+            enableBlur = obj.getBoolean("enableBlur", false),
+            topBarBlurRadius = obj.getInt("topBarBlurRadius", 24),
+            bottomBarBlurRadius = obj.getInt("bottomBarBlurRadius", 8),
+            topBarBlurAlpha = obj.getInt("topBarBlurAlpha", 73),
+            bottomBarBlurAlpha = obj.getInt("bottomBarBlurAlpha", 40),
+            bottomBarLensRadius = obj.getFloat("bottomBarLensRadius", 24f)
+        )
+        appearance.enableDeepPersonalization = if (obj.has("enableDeepPersonalization")) {
+            obj.getBoolean("enableDeepPersonalization", false)
+        } else {
+            appearance.hasDeepColorOverrides
+        }
+        return appearance
+    }
+
+    fun convertTagColorsFromHex(json: String?): String? {
+        if (json.isNullOrBlank()) return json
+        return kotlin.runCatching {
+            val root = JsonParser.parseString(json)
+            if (root.isJsonArray) {
+                val arr = root.asJsonArray
+                val converted = arr.map { elem ->
+                    val obj = elem.asJsonObject
+                    val textColor = parseColorFromJson(obj.get("textColor"))
+                    val bgColor = parseColorFromJson(obj.get("bgColor"))
+                    """{"textColor":$textColor,"bgColor":$bgColor}"""
+                }
+                "[\n  ${converted.joinToString(",\n  ")}\n]"
+            } else json
+        }.getOrNull() ?: json
+    }
+
+    fun readColor(obj: JsonObject, vararg names: String): Int {
+        for (name in names) {
+            val color = obj.getInt(name, Int.MIN_VALUE)
+            if (color != Int.MIN_VALUE) return color
+        }
+        return 0
+    }
+
+    fun colorToHex(color: Int): String = "#${String.format("%08X", color)}"
+
+    private fun convertTagColorsToHex(json: String?): String? {
+        if (json.isNullOrBlank()) return json
+        return kotlin.runCatching {
+            val colors = GSON.fromJson(json, Array<TagColorPair>::class.java)
+            val converted = colors.map { pair ->
+                val textHex = colorToHex(pair.textColor)
+                val bgHex = colorToHex(pair.bgColor)
+                """{"textColor":"$textHex","bgColor":"$bgHex"}"""
+            }
+            "[\n  ${converted.joinToString(",\n  ")}\n]"
+        }.getOrNull() ?: json
+    }
+
+    private fun parseColorFromJson(elem: JsonElement?): Int {
+        if (elem == null || elem.isJsonNull) return 0
+        val prim = elem.asJsonPrimitive
+        return when {
+            prim.isNumber -> prim.asNumber.toInt()
+            prim.isString -> parseColorString(prim.asString)
+            else -> 0
+        }
+    }
+
+    private fun JsonObject.getInt(name: String, default: Int): Int {
+        val elem = get(name)
+        return when {
+            elem == null || elem.isJsonNull -> default
+            elem.isJsonPrimitive -> {
+                val prim = elem.asJsonPrimitive
+                when {
+                    prim.isNumber -> prim.asNumber.toInt()
+                    prim.isString -> parseColorString(prim.asString)
+                    else -> default
+                }
+            }
+            else -> default
+        }
+    }
+
+    private fun parseColorString(colorStr: String): Int {
+        val text = colorStr.trim()
+        if (text.isEmpty()) return 0
+        return kotlin.runCatching {
+            if (text.startsWith("#")) {
+                text.substring(1).toLong(16).toInt()
+            } else {
+                text.toLong().toInt()
+            }
+        }.getOrElse {
+            kotlin.runCatching { text.toColorInt() }.getOrDefault(0)
+        }
+    }
+}

+ 327 - 0
app/src/main/java/io/legado/app/help/config/ThemePackConfig.kt

@@ -0,0 +1,327 @@
+package io.legado.app.help.config
+
+import android.net.Uri
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import io.legado.app.ui.config.coverConfig.CoverConfig
+import io.legado.app.ui.config.mainConfig.MainConfig
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.utils.FileUtils
+import io.legado.app.utils.externalFiles
+import splitties.init.appCtx
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipInputStream
+import java.util.zip.ZipOutputStream
+
+object ThemePackConfig {
+
+    private const val CONFIG_FILE = "theme.json"
+    private const val DIR_NAME = "theme_packs"
+    private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
+
+    private val PACK_GSON: Gson by lazy {
+        GsonBuilder()
+            .registerTypeAdapter(ThemePack::class.java, ThemePackSerializer())
+            .setPrettyPrinting()
+            .disableHtmlEscaping()
+            .create()
+    }
+
+    private val _packList = mutableListOf<ThemePack>()
+    val packList: List<ThemePack> get() = _packList
+
+    init {
+        loadAll()
+    }
+
+    private fun loadAll() {
+        _packList.clear()
+        baseDir.mkdirs()
+        baseDir.listFiles()?.forEach { dir ->
+            if (dir.isDirectory) {
+                val configFile = File(dir, CONFIG_FILE)
+                if (configFile.exists()) {
+                    kotlin.runCatching {
+                        val json = configFile.readText()
+                        val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
+                        if (pack.name.isNotEmpty()) {
+                            pack.folderName = dir.name
+                            _packList.add(pack)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    fun reload() {
+        loadAll()
+    }
+
+    fun getPackDir(pack: ThemePack): File {
+        return File(baseDir, pack.folderName)
+    }
+
+    fun savePack(pack: ThemePack) {
+        val dir = getPackDir(pack)
+        dir.mkdirs()
+        val configFile = File(dir, CONFIG_FILE)
+        val json = PACK_GSON.toJson(pack)
+        FileUtils.createFileIfNotExist(configFile.absolutePath).writeText(json)
+    }
+
+    fun deletePack(pack: ThemePack) {
+        val dir = getPackDir(pack)
+        if (dir.exists()) {
+            dir.deleteRecursively()
+        }
+        _packList.remove(pack)
+    }
+
+    fun createFromCurrent(name: String): ThemePack {
+        val folderName = System.currentTimeMillis().toString()
+        val pack = ThemePack(
+            name = name,
+            folderName = folderName,
+            appearance = ThemeAppearanceConfig.fromCurrent(),
+            bgImageLight = ThemeConfig.bgImageLight,
+            bgImageDark = ThemeConfig.bgImageDark,
+            bgImageBlurring = ThemeConfig.bgImageBlurring,
+            bgImageNBlurring = ThemeConfig.bgImageNBlurring,
+            navIconBookshelf = MainConfig.navIconBookshelf,
+            navIconExplore = MainConfig.navIconExplore,
+            navIconRss = MainConfig.navIconRss,
+            navIconMy = MainConfig.navIconMy,
+            coverTextColor = CoverConfig.coverTextColor,
+            coverShadowColor = CoverConfig.coverShadowColor,
+            coverTextColorN = CoverConfig.coverTextColorN,
+            coverShadowColorN = CoverConfig.coverShadowColorN,
+            coverShowName = CoverConfig.coverShowName,
+            coverShowAuthor = CoverConfig.coverShowAuthor,
+            coverShowNameN = CoverConfig.coverShowNameN,
+            coverShowAuthorN = CoverConfig.coverShowAuthorN,
+            coverDefaultColor = CoverConfig.coverDefaultColor,
+            coverShowShadow = CoverConfig.coverShowShadow,
+            coverShowStroke = CoverConfig.coverShowStroke,
+            coverInfoOrientation = CoverConfig.coverInfoOrientation,
+            defaultCover = CoverConfig.defaultCover,
+            defaultCoverDark = CoverConfig.defaultCoverDark
+        )
+
+        val dir = File(baseDir, folderName)
+        dir.mkdirs()
+
+        copyResourceFile(pack.appearance.appFontPath, dir, "app_font")
+        copyResourceFile(pack.bgImageLight, dir, "bg_light")
+        copyResourceFile(pack.bgImageDark, dir, "bg_dark")
+        copyResourceFile(pack.navIconBookshelf, dir, "nav_bookshelf")
+        copyResourceFile(pack.navIconExplore, dir, "nav_explore")
+        copyResourceFile(pack.navIconRss, dir, "nav_rss")
+        copyResourceFile(pack.navIconMy, dir, "nav_my")
+
+        copyCoverImages(pack.defaultCover, dir, "cover_day")
+        copyCoverImages(pack.defaultCoverDark, dir, "cover_night")
+
+        savePack(pack)
+        _packList.add(pack)
+        return pack
+    }
+
+    fun applyPack(pack: ThemePack) {
+        val dir = getPackDir(pack)
+        val restoredAppearance = pack.appearance.copy(
+            appFontPath = restoreResourcePath(dir, "app_font", pack.appearance.appFontPath)
+        )
+        restoredAppearance.applyToThemeConfig()
+
+        ThemeConfig.bgImageLight = restoreResourcePath(dir, "bg_light", pack.bgImageLight)
+        ThemeConfig.bgImageDark = restoreResourcePath(dir, "bg_dark", pack.bgImageDark)
+        ThemeConfig.bgImageBlurring = pack.bgImageBlurring
+        ThemeConfig.bgImageNBlurring = pack.bgImageNBlurring
+
+        MainConfig.navIconBookshelf = restoreResourcePath(dir, "nav_bookshelf", pack.navIconBookshelf) ?: ""
+        MainConfig.navIconExplore = restoreResourcePath(dir, "nav_explore", pack.navIconExplore) ?: ""
+        MainConfig.navIconRss = restoreResourcePath(dir, "nav_rss", pack.navIconRss) ?: ""
+        MainConfig.navIconMy = restoreResourcePath(dir, "nav_my", pack.navIconMy) ?: ""
+
+        CoverConfig.coverTextColor = pack.coverTextColor
+        CoverConfig.coverShadowColor = pack.coverShadowColor
+        CoverConfig.coverTextColorN = pack.coverTextColorN
+        CoverConfig.coverShadowColorN = pack.coverShadowColorN
+        CoverConfig.coverShowName = pack.coverShowName
+        CoverConfig.coverShowAuthor = pack.coverShowAuthor
+        CoverConfig.coverShowNameN = pack.coverShowNameN
+        CoverConfig.coverShowAuthorN = pack.coverShowAuthorN
+        CoverConfig.coverDefaultColor = pack.coverDefaultColor
+        CoverConfig.coverShowShadow = pack.coverShowShadow
+        CoverConfig.coverShowStroke = pack.coverShowStroke
+        CoverConfig.coverInfoOrientation = pack.coverInfoOrientation
+        CoverConfig.defaultCover = restoreCoverImages(dir, "cover_day", pack.defaultCover)
+        CoverConfig.defaultCoverDark = restoreCoverImages(dir, "cover_night", pack.defaultCoverDark)
+    }
+
+    fun exportToZip(pack: ThemePack, destFile: File) {
+        val dir = getPackDir(pack)
+        if (!dir.exists()) return
+        ZipOutputStream(FileOutputStream(destFile)).use { zos ->
+            dir.walkTopDown().forEach { file ->
+                if (file.isFile) {
+                    val entryName = file.relativeTo(dir).path.replace('\\', '/')
+                    zos.putNextEntry(ZipEntry(entryName))
+                    FileInputStream(file).use { fis ->
+                        fis.copyTo(zos)
+                    }
+                    zos.closeEntry()
+                }
+            }
+        }
+    }
+
+    fun importFromZip(zipFile: File): ThemePack? {
+        val folderName = System.currentTimeMillis().toString()
+        val dir = File(baseDir, folderName)
+        dir.mkdirs()
+
+        return kotlin.runCatching {
+            ZipInputStream(FileInputStream(zipFile)).use { zis ->
+                var entry = zis.nextEntry
+                while (entry != null) {
+                    val outFile = resolveZipOutputFile(dir, entry)
+                    if (entry.isDirectory) {
+                        outFile.mkdirs()
+                    } else {
+                        outFile.parentFile?.mkdirs()
+                        FileOutputStream(outFile).use { fos ->
+                            zis.copyTo(fos)
+                        }
+                    }
+                    zis.closeEntry()
+                    entry = zis.nextEntry
+                }
+            }
+
+            val configFile = File(dir, CONFIG_FILE)
+            if (!configFile.exists()) {
+                dir.deleteRecursively()
+                return null
+            }
+
+            val json = configFile.readText()
+            val pack = PACK_GSON.fromJson(json, ThemePack::class.java)
+            pack.folderName = folderName
+            _packList.add(pack)
+            pack
+        }.onFailure {
+            dir.deleteRecursively()
+        }.getOrNull()
+    }
+
+    private fun resolveZipOutputFile(dir: File, entry: ZipEntry): File {
+        val outFile = File(dir, entry.name).canonicalFile
+        val canonicalDir = dir.canonicalFile
+        if (outFile.path == canonicalDir.path ||
+            outFile.path.startsWith(canonicalDir.path + File.separator)
+        ) {
+            return outFile
+        }
+        throw IllegalArgumentException("Invalid theme pack entry: ${entry.name}")
+    }
+
+    private fun copyResourceFile(sourcePath: String?, destDir: File, prefix: String) {
+        if (sourcePath.isNullOrEmpty()) return
+        val sourceUri = Uri.parse(sourcePath)
+        val sourceName = sourceUri.lastPathSegment ?: sourcePath
+        val ext = sourceName.substringAfterLast(".", "dat").ifBlank { "dat" }
+        val destFile = File(destDir, "$prefix.$ext")
+
+        if (sourceUri.scheme == "content") {
+            appCtx.contentResolver.openInputStream(sourceUri)?.use { input ->
+                FileOutputStream(destFile).use { output ->
+                    input.copyTo(output)
+                }
+            }
+            return
+        }
+
+        val sourceFile = File(sourcePath)
+        if (!sourceFile.exists()) return
+        sourceFile.copyTo(destFile, overwrite = true)
+    }
+
+    private fun restoreResourcePath(packDir: File, prefix: String, originalPath: String?): String? {
+        if (originalPath.isNullOrEmpty()) return null
+        val candidates = packDir.listFiles { file ->
+            file.name.startsWith(prefix) && file.isFile
+        }
+        if (candidates.isNullOrEmpty()) return null
+        val sourceFile = candidates.first()
+        val targetDir = File(appCtx.filesDir, "theme_pack_resources")
+        targetDir.mkdirs()
+        val targetFile = File(targetDir, "${System.currentTimeMillis()}_${sourceFile.name}")
+        sourceFile.copyTo(targetFile, overwrite = true)
+        return targetFile.absolutePath
+    }
+
+    private fun copyCoverImages(coverPaths: String, destDir: File, prefix: String) {
+        val paths = coverPaths.split(",").filter { it.isNotBlank() }
+        paths.forEachIndexed { index, path ->
+            copyResourceFile(path, destDir, "${prefix}_$index")
+        }
+    }
+
+    private fun restoreCoverImages(packDir: File, prefix: String, originalPaths: String): String {
+        if (originalPaths.isEmpty()) return ""
+        val candidates = packDir.listFiles { file ->
+            file.name.startsWith(prefix + "_") && file.isFile
+        }
+        if (candidates.isNullOrEmpty()) return originalPaths
+        val sortedCandidates = candidates.sortedBy { file ->
+            file.name.substringAfterLast("_").substringBefore(".").toIntOrNull() ?: 0
+        }
+        val coverDir = File(appCtx.externalFiles, "covers")
+        coverDir.mkdirs()
+        val restoredPaths = mutableListOf<String>()
+        sortedCandidates.forEach { sourceFile ->
+            val targetFile = File(coverDir, sourceFile.name)
+            sourceFile.copyTo(targetFile, overwrite = true)
+            restoredPaths.add(targetFile.absolutePath)
+        }
+        return if (restoredPaths.isEmpty()) originalPaths else restoredPaths.joinToString(",")
+    }
+
+    data class ThemePack(
+        var name: String = "",
+        @Transient
+        var folderName: String = "",
+        var appearance: ThemeAppearanceConfig = ThemeAppearanceConfig(),
+        var bgImageLight: String? = null,
+        var bgImageDark: String? = null,
+        var bgImageBlurring: Int = 0,
+        var bgImageNBlurring: Int = 0,
+        var navIconBookshelf: String = "",
+        var navIconExplore: String = "",
+        var navIconRss: String = "",
+        var navIconMy: String = "",
+        var coverTextColor: Int = -16777216,
+        var coverShadowColor: Int = -16777216,
+        var coverTextColorN: Int = -1,
+        var coverShadowColorN: Int = -1,
+        var coverShowName: Boolean = true,
+        var coverShowAuthor: Boolean = true,
+        var coverShowNameN: Boolean = true,
+        var coverShowAuthorN: Boolean = true,
+        var coverDefaultColor: Boolean = true,
+        var coverShowShadow: Boolean = false,
+        var coverShowStroke: Boolean = true,
+        var coverInfoOrientation: String = "0",
+        var defaultCover: String = "",
+        var defaultCoverDark: String = ""
+    ) {
+        val themeColor: Int get() = appearance.themeColor
+        val appFontPath: String? get() = appearance.appFontPath
+    }
+}
+

+ 83 - 0
app/src/main/java/io/legado/app/help/config/ThemePackSerializer.kt

@@ -0,0 +1,83 @@
+package io.legado.app.help.config
+
+import com.google.gson.JsonDeserializationContext
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonSerializer
+import io.legado.app.utils.getBoolean
+import io.legado.app.utils.getInt
+import io.legado.app.utils.getString
+import java.lang.reflect.Type
+
+class ThemePackSerializer : JsonSerializer<ThemePackConfig.ThemePack>,
+    JsonDeserializer<ThemePackConfig.ThemePack> {
+
+    override fun serialize(
+        src: ThemePackConfig.ThemePack,
+        typeOfSrc: Type,
+        context: JsonSerializationContext
+    ): JsonElement {
+        val obj = JsonObject()
+        obj.addProperty("name", src.name)
+        ThemeAppearanceJson.writeTo(obj, src.appearance)
+        obj.addProperty("bgImageLight", src.bgImageLight)
+        obj.addProperty("bgImageDark", src.bgImageDark)
+        obj.addProperty("bgImageBlurring", src.bgImageBlurring)
+        obj.addProperty("bgImageNBlurring", src.bgImageNBlurring)
+        obj.addProperty("navIconBookshelf", src.navIconBookshelf)
+        obj.addProperty("navIconExplore", src.navIconExplore)
+        obj.addProperty("navIconRss", src.navIconRss)
+        obj.addProperty("navIconMy", src.navIconMy)
+        obj.addProperty("coverTextColor", ThemeAppearanceJson.colorToHex(src.coverTextColor))
+        obj.addProperty("coverShadowColor", ThemeAppearanceJson.colorToHex(src.coverShadowColor))
+        obj.addProperty("coverTextColorN", ThemeAppearanceJson.colorToHex(src.coverTextColorN))
+        obj.addProperty("coverShadowColorN", ThemeAppearanceJson.colorToHex(src.coverShadowColorN))
+        obj.addProperty("coverShowName", src.coverShowName)
+        obj.addProperty("coverShowAuthor", src.coverShowAuthor)
+        obj.addProperty("coverShowNameN", src.coverShowNameN)
+        obj.addProperty("coverShowAuthorN", src.coverShowAuthorN)
+        obj.addProperty("coverDefaultColor", src.coverDefaultColor)
+        obj.addProperty("coverShowShadow", src.coverShowShadow)
+        obj.addProperty("coverShowStroke", src.coverShowStroke)
+        obj.addProperty("coverInfoOrientation", src.coverInfoOrientation)
+        obj.addProperty("defaultCover", src.defaultCover)
+        obj.addProperty("defaultCoverDark", src.defaultCoverDark)
+        return obj
+    }
+
+    override fun deserialize(
+        json: JsonElement,
+        typeOfT: Type,
+        context: JsonDeserializationContext
+    ): ThemePackConfig.ThemePack {
+        val obj = json.asJsonObject
+        return ThemePackConfig.ThemePack(
+            name = obj.getString("name", "") ?: "",
+            appearance = ThemeAppearanceJson.readFrom(obj),
+            bgImageLight = obj.getString("bgImageLight", null),
+            bgImageDark = obj.getString("bgImageDark", null),
+            bgImageBlurring = obj.getInt("bgImageBlurring", 0),
+            bgImageNBlurring = obj.getInt("bgImageNBlurring", 0),
+            navIconBookshelf = obj.getString("navIconBookshelf", "") ?: "",
+            navIconExplore = obj.getString("navIconExplore", "") ?: "",
+            navIconRss = obj.getString("navIconRss", "") ?: "",
+            navIconMy = obj.getString("navIconMy", "") ?: "",
+            coverTextColor = ThemeAppearanceJson.readColor(obj, "coverTextColor").takeIf { it != 0 } ?: -16777216,
+            coverShadowColor = ThemeAppearanceJson.readColor(obj, "coverShadowColor").takeIf { it != 0 } ?: -16777216,
+            coverTextColorN = ThemeAppearanceJson.readColor(obj, "coverTextColorN").takeIf { it != 0 } ?: -1,
+            coverShadowColorN = ThemeAppearanceJson.readColor(obj, "coverShadowColorN").takeIf { it != 0 } ?: -1,
+            coverShowName = obj.getBoolean("coverShowName", true),
+            coverShowAuthor = obj.getBoolean("coverShowAuthor", true),
+            coverShowNameN = obj.getBoolean("coverShowNameN", true),
+            coverShowAuthorN = obj.getBoolean("coverShowAuthorN", true),
+            coverDefaultColor = obj.getBoolean("coverDefaultColor", true),
+            coverShowShadow = obj.getBoolean("coverShowShadow", false),
+            coverShowStroke = obj.getBoolean("coverShowStroke", true),
+            coverInfoOrientation = obj.getString("coverInfoOrientation", "0") ?: "0",
+            defaultCover = obj.getString("defaultCover", "") ?: "",
+            defaultCoverDark = obj.getString("defaultCoverDark", "") ?: ""
+        )
+    }
+}

+ 4 - 0
app/src/main/java/io/legado/app/help/storage/Backup.kt

@@ -14,6 +14,7 @@ import io.legado.app.help.DirectLinkUpload
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.OldThemeConfig
+import io.legado.app.help.config.PersonalizationThemeConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.BookCover
@@ -83,6 +84,7 @@ object Backup {
             ReadBookConfig.configFileName,
             ReadBookConfig.shareConfigFileName,
             OldThemeConfig.configFileName,
+            PersonalizationThemeConfig.configFileName,
             BookCover.configFileName,
             "config.xml"
         )
@@ -181,6 +183,8 @@ object Backup {
             FileUtils.createFileIfNotExist(backupPath + File.separator + BookCover.configFileName)
                 .writeText(GSON.toJson(it))
         }
+        FileUtils.createFileIfNotExist(backupPath + File.separator + PersonalizationThemeConfig.configFileName)
+            .writeText(PersonalizationThemeConfig.toJson())
         currentCoroutineContext().ensureActive()
         appCtx.getSharedPreferences(backupPath, "config")?.let { sp ->
             sp.edit(commit = true) {

+ 6 - 7
app/src/main/java/io/legado/app/help/storage/BackupConfig.kt

@@ -23,6 +23,7 @@ object BackupConfig {
     private const val readConfigKey = "readConfig"
     private const val themeConfigKey = "themeConfig"
     private const val coverConfigKey = "coverConfig"
+    private const val personalizationThemeConfigKey = "personalizationThemeConfig"
     private const val localBookKey = "localBook"
 
     //配置忽略key
@@ -30,6 +31,7 @@ object BackupConfig {
         readConfigKey,
         PreferKey.themeMode,
         themeConfigKey,
+        personalizationThemeConfigKey,
         coverConfigKey,
         PreferKey.bookshelfLayout,
         PreferKey.showRss,
@@ -42,6 +44,7 @@ object BackupConfig {
         appCtx.getString(R.string.read_config),
         appCtx.getString(R.string.theme_mode),
         appCtx.getString(R.string.theme_config),
+        "深度个性化主题",
         appCtx.getString(R.string.cover_config),
         appCtx.getString(R.string.bookshelf_layout),
         appCtx.getString(R.string.show_rss),
@@ -84,15 +87,9 @@ object BackupConfig {
 
     private val themePrefKeys = arrayOf(
         PreferKey.cPrimary,
-        PreferKey.cAccent,
-        PreferKey.cBackground,
-        PreferKey.cBBackground,
+        PreferKey.cNPrimary,
         PreferKey.bgImage,
         PreferKey.bgImageBlurring,
-        PreferKey.cNPrimary,
-        PreferKey.cNAccent,
-        PreferKey.cNBackground,
-        PreferKey.cNBBackground,
         PreferKey.bgImageN,
         PreferKey.bgImageNBlurring
     )
@@ -128,6 +125,8 @@ object BackupConfig {
         get() = ignoreConfig[themeConfigKey] == true
     private val ignoreCoverConfig: Boolean
         get() = ignoreConfig[coverConfigKey] == true
+    val ignorePersonalizationThemeConfig: Boolean
+        get() = ignoreConfig[personalizationThemeConfigKey] == true
     private val ignoreBookshelfLayout: Boolean
         get() = ignoreConfig[PreferKey.bookshelfLayout] == true
     private val ignoreShowRss: Boolean

+ 11 - 0
app/src/main/java/io/legado/app/help/storage/Restore.kt

@@ -34,6 +34,7 @@ import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.upType
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.OldThemeConfig
+import io.legado.app.help.config.PersonalizationThemeConfig
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.model.BookCover
 import io.legado.app.model.localBook.LocalBook
@@ -233,6 +234,16 @@ object Restore {
         }?.onFailure {
             AppLog.put("恢复封面规则出错\n${it.localizedMessage}", it)
         }
+        if (!BackupConfig.ignorePersonalizationThemeConfig) {
+            File(path, PersonalizationThemeConfig.configFileName).takeIf {
+                it.exists()
+            }?.runCatching {
+                val json = readText()
+                PersonalizationThemeConfig.fromJson(json)
+            }?.onFailure {
+                AppLog.put("恢复个性化主题出错\n${it.localizedMessage}", it)
+            }
+        }
         if (!BackupConfig.ignoreReadConfig) {
             //恢复阅读界面配置
             File(path, ReadBookConfig.configFileName).takeIf {

+ 6 - 6
app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt

@@ -24,9 +24,9 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
 
     private suspend fun getLatestRelease(): List<AppReleaseInfo> {
         val url = if (checkVariant == AppVariant.OFFICIAL)
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest"
+            "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/latest"
         else
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases"
+            "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases"
 
         val res = okHttpClient.newCallResponse { url(url) }
         if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
@@ -65,7 +65,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
     }
 
     suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
-        val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag"
+        val url = "https://api.github.com/repos/325506/legado-with-MD3-DIY/releases/tags/$tag"
         val res = okHttpClient.newCallResponse { url(url) }
         if (!res.isSuccessful) return null
 
@@ -86,7 +86,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
             val currentVersion = AppConst.appInfo.versionName
             val releases = getLatestRelease()
 
-            val filtered = if (checkVariant == AppVariant.ALL) {
+            val filtered = if (checkVariant == AppVariant.ALL || checkVariant == AppVariant.UNKNOWN) {
                 releases
             } else {
                 releases.filter { it.appVariant == checkVariant }
@@ -153,8 +153,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
 
         companion object {
             fun parse(version: String): SemVer {
-                val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?""")
-                val match = regex.matchEntire(version)
+                val regex = Regex("""(\d+)\.(\d+)\.(\d+)(?:[-_]([\w.]+))?""")
+                val match = regex.find(version)
                     ?: throw IllegalArgumentException("Invalid version: $version")
                 val (maj, min, pat, pre) = match.destructured
                 return SemVer(maj.toInt(), min.toInt(), pat.toInt(), pre.ifBlank { null })

+ 198 - 126
app/src/main/java/io/legado/app/help/webView/WebJsExtensions.kt

@@ -9,6 +9,8 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.CacheManager
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.ReadBook
@@ -17,22 +19,25 @@ import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.ui.rss.read.RssJsExtensions
 import io.legado.app.utils.GSON
-import io.legado.app.utils.escapeForJs
 import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isJsonObject
 import java.lang.ref.WeakReference
 import java.util.UUID
 
 @Suppress("unused")
 class WebJsExtensions(
-    source: BaseSource, activity: AppCompatActivity,
-    private val webView: WebView,
-    private val bookType: Int = 0,
+    source: BaseSource,
+    activity: AppCompatActivity?,
+    webView: WebView,
+    bookType: Int = 0,
     callback: Callback? = null
 ) : RssJsExtensions(activity, source) {
     private val callbackRef: WeakReference<Callback> = WeakReference(callback)
+    private val webViewRef: WeakReference<WebView?> = WeakReference(webView)
 
     interface Callback {
         fun upConfig(config: String)
+        fun onNavigateToArticles(sortUrl: String? = null)
     }
 
     @JavascriptInterface
@@ -40,6 +45,16 @@ class WebJsExtensions(
         callbackRef.get()?.upConfig(config)
     }
 
+    @JavascriptInterface
+    fun navigateToArticles() {
+        callbackRef.get()?.onNavigateToArticles(null)
+    }
+
+    @JavascriptInterface
+    fun navigateToArticles(sortUrl: String?) {
+        callbackRef.get()?.onNavigateToArticles(sortUrl)
+    }
+
     private val bookAndChapter by lazy {
         var book: Book? = null
         var chapter: BookChapter? = null
@@ -63,154 +78,201 @@ class WebJsExtensions(
     private val book: Book? get() = bookAndChapter.first
     private val chapter: BookChapter? get() = bookAndChapter.second
 
-    /**
-     * 由软件主动注入的js函数调用
-     */
+    val analyzeRule by lazy {
+        AnalyzeRule(book, source = getSource()).setChapter(chapter)
+    }
+
     @JavascriptInterface
-    fun request(funName: String, jsParam: Array<String>, id: String) {
+    fun request(funName: String, jsParam: Array<String?>, id: String) {
         val activity = activityRef.get() ?: return
         Coroutine.async(activity.lifecycleScope) {
+            val params = Array(6) { i -> jsParam.getOrNull(i) }
+            val p0 = params[0]
+            val p1 = params[1]
+            val p2 = params[2]
+            val p3 = params[3]
+            val p4 = params[4]
+            val p5 = params[5]
             when (funName) {
-                "run" -> AnalyzeRule(book, getSource()).run {
-                    setCoroutineContext(coroutineContext)
-                    setChapter(chapter)
-                    evalJS(jsParam[0]).toString()
+                "run" -> {
+                    analyzeRule.setCoroutineContext(coroutineContext)
+                        .evalJS(
+                            p0 ?: throw NoStackTraceException("error null")
+                        ).toString()
                 }
                 "ajaxAwait" -> {
-                    ajax(jsParam[0], jsParam[1].toIntOrNull()).toString()
+                    ajax(
+                        p0 ?: throw NoStackTraceException("error url null"),
+                        p1?.toLongOrNull()
+                    ).toString()
                 }
                 "connectAwait" -> {
-                    connect(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                    connect(
+                        p0 ?: throw NoStackTraceException("error url null"),
+                        p1,
+                        p2?.toIntOrNull()
+                    )
                 }
                 "getAwait" -> {
-                    get(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                    get(
+                        p0 ?: throw NoStackTraceException("error url null"),
+                        p1 ?: throw NoStackTraceException("error header null"),
+                        p2?.toIntOrNull()
+                    )
                 }
                 "headAwait" -> {
-                    head(jsParam[0], jsParam[1], jsParam[2].toIntOrNull())
+                    head(
+                        p0 ?: throw NoStackTraceException("error url null"),
+                        p1 ?: throw NoStackTraceException("error header null"),
+                        p2?.toIntOrNull()
+                    )
                 }
                 "postAwait" -> {
-                    post(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toIntOrNull())
+                    post(
+                        p0 ?: throw NoStackTraceException("error url null"),
+                        p1 ?: throw NoStackTraceException("error body null"),
+                        p2 ?: throw NoStackTraceException("error header null"),
+                        p3?.toIntOrNull()
+                    )
                 }
                 "webViewAwait" -> {
-                    webView(jsParam[0], jsParam[1], jsParam[2], jsParam[3].toBoolean()).toString()
+                    webView(
+                        p0,
+                        p1,
+                        p2,
+                        p3.toBoolean()
+                    ).toString()
                 }
                 "webViewGetSourceAwait" -> {
                     webViewGetSource(
-                        jsParam[0],
-                        jsParam[1],
-                        jsParam[2],
-                        jsParam[3],
-                        jsParam[4].toBoolean(),
-                        jsParam[5].toLongOrNull() ?: 0
+                        p0,
+                        p1,
+                        p2,
+                        p3 ?: throw NoStackTraceException("error sourceRegex null"),
+                        p4.toBoolean(),
+                        p5?.toLongOrNull() ?: 0
                     ).toString()
                 }
                 "decryptStrAwait" -> {
-                    createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).decryptStr(jsParam[3])
+                    createSymmetricCrypto(
+                        p0 ?: throw NoStackTraceException("error transformation null"),
+                        p1 ?: throw NoStackTraceException("error key null"),
+                        p2
+                    ).decryptStr(p3 ?: throw NoStackTraceException("error data null"))
                 }
                 "encryptBase64Await" -> {
                     createSymmetricCrypto(
-                        jsParam[0],
-                        jsParam[1],
-                        jsParam[2]
-                    ).encryptBase64(jsParam[3])
+                        p0 ?: throw NoStackTraceException("error transformation null"),
+                        p1 ?: throw NoStackTraceException("error key null"),
+                        p2
+                    ).encryptBase64(p3 ?: throw NoStackTraceException("error data null"))
                 }
                 "encryptHexAwait" -> {
-                    createSymmetricCrypto(jsParam[0], jsParam[1], jsParam[2]).encryptHex(jsParam[3])
+                    createSymmetricCrypto(
+                        p0 ?: throw NoStackTraceException("error transformation null"),
+                        p1 ?: throw NoStackTraceException("error key null"),
+                        p2
+                    ).encryptHex(p3 ?: throw NoStackTraceException("error data null"))
                 }
                 "createSignHexAwait" -> {
-                    createSign(jsParam[0]).setPublicKey(jsParam[1]).setPrivateKey(jsParam[2])
-                        .signHex(jsParam[3])
+                    createSign(p0 ?: throw NoStackTraceException("error algorithm null"))
+                        .setPublicKey(p1 ?: throw NoStackTraceException("error publicKey null"))
+                        .setPrivateKey(p2 ?: throw NoStackTraceException("error privateKey null"))
+                        .signHex(p3 ?: throw NoStackTraceException("error data null"))
                 }
                 "downloadFileAwait" -> {
-                    downloadFile(jsParam[0])
+                    downloadFile(p0 ?: throw NoStackTraceException("error url null"))
                 }
                 "readTxtFileAwait" -> {
-                    readTxtFile(jsParam[0])
+                    readTxtFile(p0 ?: throw NoStackTraceException("error path null"))
                 }
                 "importScriptAwait" -> {
-                    importScript(jsParam[0])
+                    importScript(p0 ?: throw NoStackTraceException("error path null"))
                 }
-                "getStringAwait" -> AnalyzeRule(book, getSource()).run {
-                    setCoroutineContext(coroutineContext)
-                    setChapter(chapter)
-                    getString(jsParam[0], jsParam[1])
+                "getStringAwait" -> {
+                    analyzeRule.setCoroutineContext(coroutineContext)
+                        .getString(p0, p1)
                 }
-                else -> "error funName"
+                else -> throw NoStackTraceException("error funName")
             }
         }.onSuccess { data ->
-            webView.evaluateJavascript(
-                "window.$JSBridgeResult('$id', '${data.escapeForJs()}', null);",
-                null
-            )
+            CacheManager.putMemory(id, data)
+            webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', true);", null)
         }.onError {
-            webView.evaluateJavascript(
-                "window.$JSBridgeResult('$id', null, '${it.localizedMessage?.escapeForJs()}');",
-                null
-            )
+            CacheManager.putMemory(id, it.localizedMessage ?: "err")
+            webViewRef.get()?.evaluateJavascript("window.$JSBridgeResult('$id', false);", null)
         }
     }
+
     @JavascriptInterface
     fun toast(msg: String?) {
         super.toast(msg)
     }
+
     @JavascriptInterface
     fun longToast(msg: String?) {
         super.longToast(msg)
     }
+
     @JavascriptInterface
-    fun log(msg: String?): String {
-        return super.log(msg).toString()
-    }
-    @JavascriptInterface
-    fun ajax(url: String): String? {
-        return super.ajax(url, 9000)
-    }
-    @JavascriptInterface
-    fun ajax(url: String, callTimeout: Int?): String? {
-        return super.ajax(url, callTimeout?.toLong())
+    override fun open(name: String, url: String?, title: String?, origin: String?) {
+        when (name) {
+            "sort" -> {
+                val sortUrl = if (!url.isNullOrBlank()) {
+                    if (url.isJsonObject()) url else title?.let { "{\"$it\":\"$url\"}" } ?: url
+                } else null
+                callbackRef.get()?.onNavigateToArticles(sortUrl)
+            }
+            else -> super.open(name, url, title, origin)
+        }
     }
+
     @JavascriptInterface
-    fun connect(urlStr: String?): String {
-        if (urlStr.isNullOrEmpty()) return "error empty url"
-        return super.connect(urlStr, null, 9000).toString()
+    fun log(msg: String?): String {
+        return super.log(msg).toString()
     }
+
     @JavascriptInterface
-    fun connect(urlStr: String, header: String): String {
-        return super.connect(urlStr, header, 9000).toString()
+    @JvmOverloads
+    fun ajax(url: String, callTimeout: Int = 9000): String? {
+        return super.ajax(url, callTimeout.toLong())
     }
+
     @JavascriptInterface
-    fun connect(urlStr: String, header: String, callTimeout: Int?): String {
+    @JvmOverloads
+    fun connect(urlStr: String, header: String? = null, callTimeout: Int? = 9000): String {
         return super.connect(urlStr, header, callTimeout?.toLong()).toString()
     }
+
     @JavascriptInterface
-    fun get(urlStr: String, headers: String): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return super.get(urlStr, headerMap, 9000).body()
-    }
-    @JavascriptInterface
-    fun get(urlStr: String, headers: String, timeout: Int?): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return super.get(urlStr, headerMap, timeout).body()
+    @JvmOverloads
+    fun get(urlStr: String, header: String, timeout: Int? = 9000): String {
+        val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
+        return super.get(urlStr, headers, timeout).body()
     }
+
     @JavascriptInterface
-    fun post(urlStr: String, body: String, headers: String): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return super.post(urlStr, body, headerMap, 9000).body()
+    @JvmOverloads
+    fun post(urlStr: String, body: String, header: String, timeout: Int? = 9000): String {
+        val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
+        return super.post(urlStr, body, headers, timeout).body()
     }
+
     @JavascriptInterface
-    fun post(urlStr: String, body: String, headers: String, timeout: Int?): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return super.post(urlStr, body, headerMap, timeout).body()
+    @JvmOverloads
+    fun head(urlStr: String, header: String, timeout: Int? = 9000): String {
+        val headers = GSON.fromJsonObject<Map<String, String>>(header).getOrNull() ?: emptyMap()
+        return GSON.toJson(super.head(urlStr, headers, timeout).headers())
     }
+
     @JavascriptInterface
-    fun head(urlStr: String, headers: String): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return GSON.toJson(super.head(urlStr, headerMap, 9000).headers())
+    fun getStringList(rule: String?, mContent: String? = null, isUrl: Boolean = false): List<String>? {
+        return super.getStringList(rule, mContent, isUrl)
     }
+
     @JavascriptInterface
-    fun head(urlStr: String, headers: String, timeout: Int?): String {
-        val headerMap = GSON.fromJsonObject<Map<String, String>>(headers).getOrNull() ?: emptyMap()
-        return GSON.toJson(super.head(urlStr, headerMap, timeout).headers())
+    fun getString(ruleStr: String?, mContent: String? = null, isUrl: Boolean = false): String {
+        return super.getString(ruleStr, mContent, isUrl)
     }
 
     companion object {
@@ -218,6 +280,7 @@ class WebJsExtensions(
             val letters = "abcdefghijklmnopqrstuvwxyz"
             return letters.random()
         }
+
         val uuid by lazy {
             UUID.randomUUID().toString().replace('-', getRandomLetter()).chunked(6)
         }
@@ -234,9 +297,12 @@ class WebJsExtensions(
             "<script src=\"$nameUrl\"></script>"
         }
 
+        val getInjectionString = "try{var cache=$nameCache,source=$nameSource,java=$nameJava;}catch(e){}"
+
         val JS_INJECTION by lazy {
             """
             const requestId = n => 'req_' + n + '_' + Date.now() + '_' + Math.random().toString(36).slice(-3);
+            const params = a => a.map(p => p != null && typeof p.toString === 'function' ? p.toString() : null);
             const JSBridgeCallbacks = {};
             const java = window.$nameJava;
             delete window.$nameJava;
@@ -248,122 +314,124 @@ class WebJsExtensions(
                 return new Promise((resolve, reject) => {
                     const id = requestId("run");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("run", [String(jsCode)], id);
+                    java.request("run", [String(jsCode)], id);
                 });
             };
-            function ajaxAwait(url, callTimeout) {
+            function ajaxAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("ajaxAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("ajaxAwait", [String(url), String(callTimeout)], id);
+                    java.request("ajaxAwait", params(args), id);
                 });
             };
-            function connectAwait(url, header, callTimeout) {
+            function connectAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("connectAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("connectAwait", [String(url), String(header), String(callTimeout)], id);
+                    java.request("connectAwait", params(args), id);
                 });
             };
-            function getAwait(url, header, callTimeout) {
+            function getAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("getAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("getAwait", [String(url), String(header), String(callTimeout)], id);
+                    java.request("getAwait", params(args), id);
                 });
             };
-            function headAwait(url, header, callTimeout) {
+            function headAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("headAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("headAwait", [String(url), String(header), String(callTimeout)], id);
+                    java.request("headAwait", params(args), id);
                 });
             };
-            function postAwait(url, body, header, callTimeout) {
+            function postAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("postAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("postAwait", [String(url), String(body), String(header), String(callTimeout)], id);
+                    java.request("postAwait", params(args), id);
                 });
             };
-            function webViewAwait(html, url, js, cacheFirst) {
+            function webViewAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("webViewAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("webViewAwait", [String(html), String(url), String(js), String(cacheFirst)], id);
+                    java.request("webViewAwait", params(args), id);
                 });
             };
-            function webViewGetSourceAwait(html, url, js, sourceRegex, cacheFirst, delayTime) {
+            function webViewGetSourceAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("webViewGetSourceAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("webViewGetSourceAwait", [String(html), String(url), String(js), String(sourceRegex), String(cacheFirst), String(delayTime)], id);
+                    java.request("webViewGetSourceAwait", params(args), id);
                 });
             }
-            function decryptStrAwait(transformation, key, iv, data) {
+            function decryptStrAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("decryptStrAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("decryptStrAwait", [String(transformation), String(key), String(iv), String(data)], id);
+                    java.request("decryptStrAwait", params(args), id);
                 });
             };
-            function encryptBase64Await(transformation, key, iv, data) {
+            function encryptBase64Await(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("encryptBase64Await");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("encryptBase64Await", [String(transformation), String(key), String(iv), String(data)], id);
+                    java.request("encryptBase64Await", params(args), id);
                 });
             };
-            function encryptHexAwait(transformation, key, iv, data) {
+            function encryptHexAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("encryptHexAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("encryptHexAwait", [String(transformation), String(key), String(iv), String(data)], id);
+                    java.request("encryptHexAwait", params(args), id);
                 });
             };
-            function createSignHexAwait(algorithm, publicKey, privateKey, data) {
+            function createSignHexAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("createSignHexAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("createSignHexAwait", [String(algorithm), String(publicKey), String(privateKey), String(data)], id);
+                    java.request("createSignHexAwait", params(args), id);
                 });
             };
             function downloadFileAwait(url) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("downloadFileAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("downloadFileAwait", [String(url)], id);
+                    java.request("downloadFileAwait", [String(url)], id);
                 });
             };
             function readTxtFileAwait(path) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("readTxtFileAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("readTxtFileAwait", [String(path)], id);
+                    java.request("readTxtFileAwait", [String(path)], id);
                 });
             };
             function importScriptAwait(url) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("importScriptAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("importScriptAwait", [String(url)], id);
+                    java.request("importScriptAwait", [String(url)], id);
                 });
             };
-            function getStringAwait(ruleStr, mContent) {
+            function getStringAwait(...args) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("getStringAwait");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("getStringAwait", [String(ruleStr), String(mContent)], id);
+                    java.request("getStringAwait", params(args), id);
                 });
             };
-            window.$JSBridgeResult = function(requestId, result, error) {
-                if (JSBridgeCallbacks[requestId]) {
-                    if (error) {
-                        JSBridgeCallbacks[requestId].reject(error);
+            window.$JSBridgeResult = function(id, success) {
+                const callBack = JSBridgeCallbacks[id];
+                if (callBack) {
+                    const result = cache.getFromMemory(id);
+                    if (success) {
+                        callBack.resolve(result);
                     } else {
-                        JSBridgeCallbacks[requestId].resolve(result);
+                        callBack.reject(result);
                     }
-                    delete JSBridgeCallbacks[requestId];
+                    delete JSBridgeCallbacks[id];
                 }
             };""".trimIndent()
         }
@@ -374,21 +442,25 @@ class WebJsExtensions(
             const JSBridgeCallbacks = {};
             const java = window.$nameJava;
             delete window.$nameJava;
+            const cache = window.$nameCache;
+            delete window.$nameCache;
             function run(jsCode) {
                 return new Promise((resolve, reject) => {
                     const id = requestId("run");
                     JSBridgeCallbacks[id] = { resolve, reject };
-                    java?.request("run", [String(jsCode)], id);
+                    java.request("run", [String(jsCode)], id);
                 });
             };
-            window.$JSBridgeResult = function(requestId, result, error) {
-                if (JSBridgeCallbacks[requestId]) {
-                    if (error) {
-                        JSBridgeCallbacks[requestId].reject(error);
+            window.$JSBridgeResult = function(id, success) {
+                const callBack = JSBridgeCallbacks[id];
+                if (callBack) {
+                    const result = cache.getFromMemory(id);
+                    if (success) {
+                        callBack.resolve(result);
                     } else {
-                        JSBridgeCallbacks[requestId].resolve(result);
+                        callBack.reject(result);
                     }
-                    delete JSBridgeCallbacks[requestId];
+                    delete JSBridgeCallbacks[id];
                 }
             };""".trimIndent()
         }
@@ -416,4 +488,4 @@ class WebJsExtensions(
             })();""".trimIndent()
         }
     }
-}
+}

+ 19 - 2
app/src/main/java/io/legado/app/model/AudioPlay.kt

@@ -59,6 +59,7 @@ object AudioPlay : CoroutineScope by MainScope() {
     var durChapterPos = 0
     var durChapter: BookChapter? = null
     var durPlayUrl = ""
+    var durLyric: String? = null
     var durAudioSize = 0
     var inBookshelf = false
     var bookSource: BookSource? = null
@@ -153,14 +154,23 @@ object AudioPlay : CoroutineScope by MainScope() {
                     .onSuccess { content ->
                         if (content.isEmpty()) {
                             appCtx.toastOnUi("未获取到资源链接")
+                            // 加载失败时清空歌词
+                            durLyric = null
+                            callback?.upLyric(null)
                         } else {
                             contentLoadFinish(chapter, content)
                         }
-                    }.onError {
-                        AppLog.put("获取资源链接出错\n$it", it, true)
+                    }.onError { error ->
+                        AppLog.put("获取资源链接出错\n$error", error, true)
                         upLoading(false)
+                        // 加载失败时清空歌词
+                        durLyric = null
+                        callback?.upLyric(null)
                     }.onCancel {
                         removeLoading(index)
+                        // 取消加载时清空歌词
+                        durLyric = null
+                        callback?.upLyric(null)
                     }.onFinally {
                         removeLoading(index)
                     }
@@ -177,7 +187,10 @@ object AudioPlay : CoroutineScope by MainScope() {
     private fun contentLoadFinish(chapter: BookChapter, content: String) {
         if (chapter.index == book?.durChapterIndex) {
             durPlayUrl = content
+            durLyric = chapter.getVariable("lyric").takeIf { it.isNotBlank() }
             upPlayUrl()
+            // 重新加载歌词,确保循环播放时歌词进度重置
+            callback?.upLyric(durLyric)
         }
     }
 
@@ -397,6 +410,8 @@ object AudioPlay : CoroutineScope by MainScope() {
     fun register(context: Context) {
         activityContext = context
         callback = context as CallBack
+        // 主动更新歌词状态,确保重新进入时歌词控件能正确显示
+        callback?.upLyric(durLyric)
     }
 
     fun unregister(context: Context) {
@@ -418,6 +433,8 @@ object AudioPlay : CoroutineScope by MainScope() {
     interface CallBack {
 
         fun upLoading(loading: Boolean)
+        fun upLyric(lyric: String?)
+        fun upLyricP(position: Int)
 
     }
 

+ 84 - 0
app/src/main/java/io/legado/app/model/Debug.kt

@@ -138,6 +138,90 @@ object Debug {
             }
     }
 
+    fun startDebug(scope: CoroutineScope, rssSource: RssSource, key: String) {
+        cancelDebug()
+        debugSource = rssSource.sourceUrl
+        startTime = System.currentTimeMillis()
+        when {
+            key.contains("@js:") -> {
+                val ruleContent = rssSource.ruleContent
+                if (ruleContent.isNullOrEmpty()) {
+                    log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
+                } else {
+                    val rssArticle = RssArticle()
+                    rssArticle.origin = rssSource.sourceUrl
+                    rssArticle.link = key
+                    log(debugSource, "⇒开始解析@js:链接:$key")
+                    rssContentDebug(scope, rssArticle, ruleContent, rssSource)
+                }
+            }
+
+            key.contains("::") -> {
+                val name = key.substringBefore("::")
+                val url = key.substringAfter("::")
+                log(debugSource, "⇒开始访问分类页:$url")
+                log(debugSource, "︾开始解析分类页")
+                rssSortDebug(scope, rssSource, name, url)
+            }
+
+            key.isAbsUrl() -> {
+                val ruleContent = rssSource.ruleContent
+                if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) {
+                    if (ruleContent.isNullOrEmpty()) {
+                        log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
+                    } else {
+                        val rssArticle = RssArticle()
+                        rssArticle.origin = rssSource.sourceUrl
+                        rssArticle.link = key
+                        log(debugSource, "⇒开始访问内容页:$key")
+                        rssContentDebug(scope, rssArticle, ruleContent, rssSource)
+                    }
+                } else {
+                    log(debugSource, "⇒存在描述规则,不解析内容页")
+                    log(debugSource, "︽解析完成", state = 1000)
+                }
+            }
+
+            else -> {
+                val searchUrl = rssSource.searchUrl
+                if (searchUrl.isNullOrEmpty()) {
+                    log(debugSource, "⇒搜索URL为空", state = -1)
+                    return
+                }
+                log(debugSource, "⇒开始搜索关键字:$key")
+                log(debugSource, "︾开始解析搜索页")
+                rssSortDebug(scope, rssSource, "搜索", searchUrl, key)
+            }
+        }
+    }
+
+    private fun rssSortDebug(scope: CoroutineScope, rssSource: RssSource, name: String, url: String, key: String? = null) {
+        Rss.getArticles(scope, name, url, rssSource, 1, key)
+            .onSuccess {
+                if (it.first.isEmpty()) {
+                    log(debugSource, "⇒列表页解析成功,为空")
+                    log(debugSource, "︽解析完成", state = 1000)
+                } else {
+                    val ruleContent = rssSource.ruleContent
+                    if (!rssSource.ruleArticles.isNullOrBlank() && rssSource.ruleDescription.isNullOrBlank()) {
+                        log(debugSource, "︽列表页解析完成")
+                        log(debugSource, showTime = false)
+                        if (ruleContent.isNullOrEmpty()) {
+                            log(debugSource, "⇒内容规则为空,默认获取整个网页", state = 1000)
+                        } else {
+                            rssContentDebug(scope, it.first[0], ruleContent, rssSource)
+                        }
+                    } else {
+                        log(debugSource, "⇒存在描述规则,不解析内容页")
+                        log(debugSource, "︽解析完成", state = 1000)
+                    }
+                }
+            }
+            .onError {
+                log(debugSource, it.stackTraceStr, state = -1)
+            }
+    }
+
     private fun rssContentDebug(
         scope: CoroutineScope,
         rssArticle: RssArticle,

+ 8 - 2
app/src/main/java/io/legado/app/model/rss/Rss.kt

@@ -24,10 +24,11 @@ object Rss {
         sortUrl: String,
         rssSource: RssSource,
         page: Int,
+        searchKey: String? = null,
         context: CoroutineContext = Dispatchers.IO
     ): Coroutine<Pair<MutableList<RssArticle>, String?>> {
         return Coroutine.async(scope, context) {
-            getArticlesAwait(sortName, sortUrl, rssSource, page)
+            getArticlesAwait(sortName, sortUrl, rssSource, page, searchKey)
         }
     }
 
@@ -36,8 +37,13 @@ object Rss {
         sortUrl: String,
         rssSource: RssSource,
         page: Int,
+        searchKey: String? = null
     ): Pair<MutableList<RssArticle>, String?> {
         val ruleData = RuleData()
+        if (!searchKey.isNullOrBlank()) {
+            ruleData.putVariable("searchKey", searchKey)
+            ruleData.putVariable("key", searchKey)
+        }
         val analyzeUrl = AnalyzeUrl(
             sortUrl,
             page = page,
@@ -100,4 +106,4 @@ object Rss {
             }
         }
     }
-}
+}

+ 9 - 2
app/src/main/java/io/legado/app/model/webBook/BookContent.kt

@@ -211,9 +211,9 @@ object BookContent {
         if (!book.isAudio && !book.isVideo) { //音频和视频获取的是链接,不需要html格式化 && !book.isVideo
             val useHtmlMap = mutableMapOf<String, String>()
             if (AppConfig.adaptSpecialStyle) {
-                content = AppPattern.useHtmlRegex.replace(content) { matchResult ->
+                content = AppPattern.useHtmlRegex.replace(content) {
                     val placeholder = "{usehtml_${useHtmlMap.size}}"
-                    useHtmlMap[placeholder] = matchResult.value
+                    useHtmlMap[placeholder] = it.value
                     placeholder
                 }
             }
@@ -225,6 +225,13 @@ object BookContent {
                 content = content.replace(placeholder, originalContent)
             }
         }
+        //获取副文(歌词等)
+        contentRule.subContent?.takeIf { it.isNotBlank() }?.let {
+            val subContent = analyzeRule.getString(it, unescape = false)
+            if (subContent.isNotBlank()) {
+                chapter.putVariable("lyric", subContent)
+            }
+        }
         //获取下一页链接
         if (getNextPageUrl) {
             val nextUrlRule = contentRule.nextContentUrl

+ 51 - 50
app/src/main/java/io/legado/app/ui/about/AboutScreen.kt

@@ -34,6 +34,7 @@ import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.settingItem.SettingItem
+import io.legado.app.ui.widget.components.SettingItemWithDivider
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -132,56 +133,56 @@ fun AboutScreen(
                 modifier = Modifier.padding(horizontal = 16.dp),
                 title = ""
             ){
-                SettingItem(
-                    title = stringResource(R.string.contributors),
-                    description = null,
-                    option = null,
-                    onClick = {
-                        onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
-                    }
-                )
-                SettingItem(
-                    title = stringResource(R.string.privacy_policy),
-                    description = null,
-                    option = null,
-                    onClick = {
-                        onShowMdFile("隐私政策", "privacyPolicy.md")
-                    }
-                )
-                SettingItem(
-                    title = stringResource(R.string.license),
-                    description = null,
-                    option = null,
-                    onClick = {
-                        onShowMdFile("许可证", "LICENSE.md")
-                    }
-                )
-                SettingItem(
-                    title = stringResource(R.string.disclaimer),
-                    description = null,
-                    option = null,
-                    onClick = {
-                        onShowMdFile("免责声明", "disclaimer.md")
-                    }
-                )
-                SettingItem(
-                    title = stringResource(R.string.crash_log),
-                    description = null,
-                    option = null,
-                    onClick = onShowCrashLogs
-                )
-                SettingItem(
-                    title = stringResource(R.string.save_log),
-                    description = null,
-                    option = null,
-                    onClick = onSaveLog
-                )
-                SettingItem(
-                    title = stringResource(R.string.create_heap_dump),
-                    description = null,
-                    option = null,
-                    onClick = onCreateHeapDump
-                )
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.contributors),
+                        onClick = {
+                            onOpenUrl("https://github.com/gedoor/legado/graphs/contributors")
+                        }
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.privacy_policy),
+                        onClick = {
+                            onShowMdFile("隐私政策", "privacyPolicy.md")
+                        }
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.license),
+                        onClick = {
+                            onShowMdFile("许可证", "LICENSE.md")
+                        }
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.disclaimer),
+                        onClick = {
+                            onShowMdFile("免责声明", "disclaimer.md")
+                        }
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.crash_log),
+                        onClick = onShowCrashLogs
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.save_log),
+                        onClick = onSaveLog
+                    )
+                }
+                SettingItemWithDivider {
+                    SettingItem(
+                        title = stringResource(R.string.create_heap_dump),
+                        onClick = onCreateHeapDump
+                    )
+                }
             }
         }
     }

+ 1 - 1
app/src/main/java/io/legado/app/ui/about/CrashReportScreen.kt

@@ -75,7 +75,7 @@ fun CrashReportScreen(
                     .fillMaxWidth()
                     .verticalScroll(rememberScrollState())
             ) {
-                Text(
+                AppText(
                     text = displayText,
                     style = LegadoTheme.typography.bodySmall,
                     fontFamily = FontFamily.Monospace

+ 81 - 1
app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt

@@ -25,10 +25,13 @@ import androidx.lifecycle.lifecycleScope
 import androidx.transition.TransitionManager
 import com.google.android.material.color.DynamicColors
 import com.google.android.material.color.DynamicColorsOptions
+import androidx.constraintlayout.widget.ConstraintLayout
 import com.google.android.material.color.MaterialColors
 import com.google.android.material.slider.Slider
 import com.google.android.material.transition.platform.MaterialContainerTransform
 import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback
+import android.graphics.Typeface
+import com.dirror.lyricviewx.OnPlayClickListener
 import io.legado.app.R
 import io.legado.app.base.VMBaseActivity
 import io.legado.app.constant.BookType
@@ -43,6 +46,7 @@ import io.legado.app.databinding.ActivityAudioPlayBinding
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.BookCover
 import io.legado.app.model.SourceCallBack
@@ -55,6 +59,7 @@ import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.ToolbarUtils.setAllIconsColor
 import io.legado.app.utils.applyNavigationBarPadding
+import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.observeEventSticky
@@ -96,6 +101,9 @@ class AudioPlayActivity :
     private var secondaryContainerFinalColor: Int = 0
     private var currentJob: Job? = null
     private var wrappedContext: Context? = null
+    private val lyricViewX by lazy { binding.lyricViewX }
+    private var lyricOn = false
+    private var oldLyric: String? = null
 
     private val progressTimeFormat by lazy {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@@ -227,6 +235,21 @@ class AudioPlayActivity :
     }
 
     private fun initView() {
+        // 应用深度个性化中设置的字体
+        ThemeConfig.appFontPath?.let {
+            try {
+                val typeface = Typeface.createFromFile(it)
+                // 为所有文本控件设置字体
+                binding.tvTitle.typeface = typeface
+                binding.tvSubTitle.typeface = typeface
+                binding.tvDurTime.typeface = typeface
+                binding.tvAllTime.typeface = typeface
+                binding.tvSpeed.typeface = typeface
+                binding.tvTimer.typeface = typeface
+            } catch (e: Exception) {
+                e.printStackTrace()
+            }
+        }
         binding.ivTimer.isEnabled = false
         binding.ivFastForward.isEnabled = false
 
@@ -279,7 +302,10 @@ class AudioPlayActivity :
 
             override fun onStopTrackingTouch(slider: Slider) {
                 adjustProgress = false
-                AudioPlay.adjustProgress(slider.value.toInt())
+                val progress = slider.value.toInt()
+                AudioPlay.adjustProgress(progress)
+                // 更新歌词进度
+                upLyricP(progress)
             }
         })
 
@@ -503,6 +529,8 @@ class AudioPlayActivity :
                 binding.playerProgress.trackActiveTintList = ColorStateList.valueOf(color)
                 binding.tvDurTime.setTextColor(color)
                 binding.tvAllTime.setTextColor(color)
+                // 更新歌词高亮颜色
+                binding.lyricViewX.setCurrentColor(color)
             }
         }
 
@@ -671,6 +699,8 @@ class AudioPlayActivity :
             val safeValue = progress.toFloat().coerceIn(slider.valueFrom, slider.valueTo)
             if (!adjustProgress) slider.value = safeValue
             binding.tvDurTime.text = progressTimeFormat.format(progress.toLong())
+            // 更新歌词进度
+            upLyricP(progress)
         }
 
         observeEventSticky<Int>(EventBus.AUDIO_BUFFER_PROGRESS) {
@@ -702,6 +732,56 @@ class AudioPlayActivity :
         }
     }
 
+    override fun upLyric(lyric: String?) {
+        if (oldLyric == lyric) return
+        oldLyric = lyric
+        if (lyric.isNullOrBlank()) {
+            // 没有歌词时,显示标题区域,隐藏歌词
+            binding.lyricViewX.gone()
+            binding.llTitle.visible()
+            return
+        }
+        // 有歌词时,隐藏标题区域,显示歌词
+        binding.llTitle.gone()
+        // 先隐藏歌词视图,避免显示旧歌词
+        binding.lyricViewX.gone()
+        lyricViewX.loadLyric(lyric)
+        binding.lyricViewX.visible()
+        if (lyricOn) {
+            upLyricP(AudioPlay.durChapterPos)
+        } else {
+            lyricOn = true
+            lyricViewX.apply {
+                setNormalTextSize(50F)
+                setCurrentTextSize(60F)
+                // 加载深度个性化中设置的字体
+                ThemeConfig.appFontPath?.let {
+                    try {
+                        val typeface = Typeface.createFromFile(it)
+                        // LyricViewX 1.3.2 版本可能不支持 setTypeface 方法
+                        // 暂时注释掉,后续可以通过 XML 或其他方式设置
+                        // setTypeface(typeface)
+                    } catch (e: Exception) {
+                        e.printStackTrace()
+                    }
+                }
+                setDraggable(true, object : OnPlayClickListener {
+                    override fun onPlayClick(time: Long): Boolean {
+                        AudioPlay.adjustProgress(time.toInt())
+                        return true
+                    }
+                })
+            }
+            lyricViewX.postDelayed({
+                upLyricP(AudioPlay.durChapterPos)
+            }, 100)
+        }
+    }
+
+    override fun upLyricP(position: Int) {
+        lyricViewX.updateTime(position.toLong(), false)
+    }
+
     override fun addToBookshelf(book: Book, toc: List<BookChapter>) {
         viewModel.addToBookshelf(book, toc) {
             toastOnUi("已添加到书架")

+ 20 - 3
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -72,6 +72,8 @@ import io.legado.app.constant.BookType
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 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.about.AppLogSheet
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.theme.LegadoTheme
@@ -402,6 +404,9 @@ private fun BookInfoTransparentTopAppBar(
 
 @Composable
 private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
+    val useDefaultCover = AppConfig.useDefaultCover || book?.customCoverUrl == "use_default_cover"
+    if (useDefaultCover) return null
+
     val imageLoader = koinInject<ImageLoader>()
     var shouldExtractColor by remember(book?.bookUrl) { mutableStateOf(false) }
 
@@ -413,7 +418,12 @@ private fun rememberBookInfoColorTheme(book: Book?): ThemeOverrideState? {
         }
     }
 
-    val coverPath = if (shouldExtractColor) book?.getDisplayCover() else null
+    val isNight = AppConfig.isNightTheme
+
+    val coverPath = if (shouldExtractColor) {
+        book?.getDisplayCover()
+    } else null
+
     val sourceOrigin = if (shouldExtractColor) book?.origin else null
     val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
     val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
@@ -469,8 +479,15 @@ private fun BookInfoTopBarActions(
 
 @Composable
 private fun BookInfoBackdrop(book: Book) {
-    val cover = book.getDisplayCover()
-    val sourceOrigin = book.origin
+    val useDefaultCover = AppConfig.useDefaultCover || book.customCoverUrl == "use_default_cover"
+    val isNight = AppConfig.isNightTheme
+
+    val cover = if (useDefaultCover) {
+        BookCover.getRandomDefaultPath(book.bookUrl, isNight)
+    } else {
+        book.getDisplayCover()
+    }
+    val sourceOrigin = if (!useDefaultCover) book.origin else null
     val loadOnlyWifi = CoverConfig.loadCoverOnlyWifi
     val context = LocalContext.current
     val imageLoader = koinInject<ImageLoader>()

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

@@ -709,19 +709,30 @@ class BookInfoViewModel(
 
     private fun refreshMeta(book: Book) {
         execute {
-            val kinds = book.getKindList().toMutableList()
+            val allKinds = book.getKindList()
+            val customKinds = allKinds.filter { it.startsWith("#") }
+            val sourceKinds = allKinds.filter { !it.startsWith("#") }.toMutableList()
             if (book.isLocal) {
                 val size = FileDoc.fromFile(book.bookUrl).size
                 if (size > 0) {
-                    kinds.add(ConvertUtils.formatFileSize(size))
+                    sourceKinds.add(ConvertUtils.formatFileSize(size))
                 }
             }
+            val mergedKinds = (customKinds + sourceKinds).distinct()
             val userGroupIds = appDb.bookGroupDao.idsSum
             val groupAnd = userGroupIds and book.group
             val hasCustomGroup = book.group > 0L && groupAnd != 0L
             val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
             val normalizedGroupNames = groupNames.ifBlank { null }
-            Triple(kinds.toList(), normalizedGroupNames, hasCustomGroup)
+            val persistedCustomKinds = currentKindLabels.filter { it.startsWith("#") }
+            val finalKinds = if (persistedCustomKinds.isNotEmpty()) {
+                (persistedCustomKinds + sourceKinds).distinct()
+            } else {
+                mergedKinds
+            }
+            book.kind = finalKinds.joinToString(",")
+            appDb.bookDao.update(book)
+            Triple(finalKinds, normalizedGroupNames, hasCustomGroup)
         }.onSuccess {
             currentKindLabels = it.first
             currentGroupNames = it.second

+ 119 - 0
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -3,6 +3,7 @@ package io.legado.app.ui.book.info.edit
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.consumeWindowInsets
@@ -18,6 +19,8 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.ImageSearch
 import androidx.compose.material.icons.filled.Replay
@@ -27,6 +30,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -35,6 +40,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
@@ -52,6 +58,7 @@ import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 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.config.themeConfig.ThemeConfig
 import io.legado.app.utils.SelectImageContract
 import io.legado.app.utils.launch
 import io.legado.app.utils.showDialogFragment
@@ -174,10 +181,17 @@ fun BookInfoEditContent(
             onCheckedChange = { viewModel.onFixedTypeChange(it) }
         )
         Spacer(modifier = Modifier.height(16.dp))
+        val bookInfoInputColor = ThemeConfig.bookInfoInputColor
+        val inputBackgroundColor = if (bookInfoInputColor != 0) {
+            Color(bookInfoInputColor)
+        } else {
+            Color.Unspecified
+        }
         AppTextField(
             value = uiState.name,
             onValueChange = { viewModel.onNameChange(it) },
             label = "书名",
+            backgroundColor = inputBackgroundColor,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -185,6 +199,7 @@ fun BookInfoEditContent(
             value = uiState.author,
             onValueChange = { viewModel.onAuthorChange(it) },
             label = "作者",
+            backgroundColor = inputBackgroundColor,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -192,13 +207,21 @@ fun BookInfoEditContent(
             value = uiState.coverUrl ?: "",
             onValueChange = { viewModel.onCoverUrlChange(it) },
             label = "封面链接",
+            backgroundColor = inputBackgroundColor,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
+        KindEditor(
+            kindList = uiState.kindList,
+            onKindListChange = { viewModel.onKindListChange(it) },
+            backgroundColor = inputBackgroundColor
+        )
+        Spacer(modifier = Modifier.height(8.dp))
         AppTextField(
             value = uiState.intro ?: "",
             onValueChange = { viewModel.onIntroChange(it) },
             label = "简介",
+            backgroundColor = inputBackgroundColor,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -206,6 +229,7 @@ fun BookInfoEditContent(
             value = uiState.remark ?: "",
             onValueChange = { viewModel.onRemarkChange(it) },
             label = "备注",
+            backgroundColor = inputBackgroundColor,
             modifier = Modifier.fillMaxWidth()
         )
     }
@@ -228,6 +252,13 @@ fun BookTypeDropdown(
         textFieldState.setTextAndPlaceCursorAtEnd(selectedType)
     }
 
+    val bookInfoInputColor = ThemeConfig.bookInfoInputColor
+    val inputBackgroundColor = if (bookInfoInputColor != 0) {
+        Color(bookInfoInputColor)
+    } else {
+        Color.Unspecified
+    }
+
     ExposedDropdownMenuBox(
         modifier = Modifier.padding(horizontal = 8.dp),
         expanded = expanded,
@@ -238,6 +269,7 @@ fun BookTypeDropdown(
             readOnly = true,
             lineLimits = TextFieldLineLimits.SingleLine,
             label = "书籍类型",
+            backgroundColor = inputBackgroundColor,
             trailingIcon = {
                 ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
             },
@@ -265,3 +297,90 @@ fun BookTypeDropdown(
         }
     }
 }
+
+@Composable
+fun KindEditor(
+    kindList: List<String>,
+    onKindListChange: (List<String>) -> Unit,
+    backgroundColor: Color
+) {
+    var newKindText by remember { mutableStateOf("") }
+
+    Column(
+        modifier = Modifier.fillMaxWidth()
+    ) {
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppTextField(
+                value = newKindText,
+                onValueChange = { newKindText = it },
+                label = "标签",
+                backgroundColor = backgroundColor,
+                modifier = Modifier.weight(1f)
+            )
+            Spacer(modifier = Modifier.width(8.dp))
+            MediumOutlinedIconButton(
+                onClick = {
+                    val trimmed = newKindText.trim()
+                    val tag = if (trimmed.startsWith("#")) trimmed else "#$trimmed"
+                    if (tag.isNotBlank() && tag !in kindList) {
+                        val sortedList = (kindList + tag).sortedWith(
+                            compareBy<String> { !it.startsWith("#") }.thenBy { it }
+                        )
+                        onKindListChange(sortedList)
+                        newKindText = ""
+                    }
+                },
+                imageVector = Icons.Default.Add
+            )
+        }
+        Spacer(modifier = Modifier.height(4.dp))
+        if (kindList.isNotEmpty()) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .horizontalScroll(rememberScrollState()),
+                horizontalArrangement = Arrangement.spacedBy(4.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                kindList.forEach { kind ->
+                    Row(
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        AppTextField(
+                            value = kind,
+                            onValueChange = { newValue ->
+                                val index = kindList.indexOf(kind)
+                                if (index >= 0) {
+                                    val newList = kindList.toMutableList()
+                                    newList[index] = newValue
+                                    onKindListChange(newList)
+                                }
+                            },
+                            label = null,
+                            backgroundColor = backgroundColor,
+                            singleLine = true,
+                            maxLines = 1,
+                            modifier = Modifier.width(80.dp)
+                        )
+                        IconButton(
+                            onClick = {
+                                onKindListChange(kindList - kind)
+                            },
+                            modifier = Modifier.padding(0.dp)
+                        ) {
+                            Icon(
+                                imageVector = Icons.Default.Close,
+                                contentDescription = "删除标签",
+                                tint = Color.Red.copy(alpha = 0.7f),
+                                modifier = Modifier.padding(4.dp)
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt

@@ -19,6 +19,7 @@ import io.legado.app.model.ReadBook
 import io.legado.app.utils.FileUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.inputStream
+import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -31,6 +32,7 @@ data class BookInfoEditUiState(
     val coverUrl: String? = null,
     val intro: String? = null,
     val remark: String? = null,
+    val kindList: List<String> = emptyList(),
     val selectedType: String = "文本",
     val bookTypes: List<String> = listOf("文本", "音频", "图片"),
     val fixedType: Boolean = false,
@@ -57,6 +59,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                     coverUrl = it.getDisplayCover(),
                     intro = it.getDisplayIntro(),
                     remark = it.remark,
+                    kindList = it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty(),
                     selectedType = _uiState.value.bookTypes[selectedTypeIndex],
                     fixedType = it.config.fixedType,
                     book = it
@@ -85,6 +88,10 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
         _uiState.value = _uiState.value.copy(remark = remark)
     }
 
+    fun onKindListChange(kindList: List<String>) {
+        _uiState.value = _uiState.value.copy(kindList = kindList)
+    }
+
     fun onBookTypeChange(bookType: String) {
         _uiState.value = _uiState.value.copy(selectedType = bookType)
     }
@@ -116,6 +123,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                 book.config.fixedType = currentState.fixedType
                 book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl
                 book.customIntro = if (currentState.intro == book.intro) null else currentState.intro
+                book.kind = currentState.kindList.joinToString(",")
                 BookHelp.updateCacheFolder(oldBook, book)
 
                 if (ReadBook.book?.bookUrl == book.bookUrl) {

+ 32 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -86,8 +86,12 @@ import io.legado.app.ui.book.read.config.ReadAloudDialog
 import io.legado.app.ui.book.read.config.ReadStyleDialog
 import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.A_COLOR
 import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.B_COLOR
-import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_COLOR
+import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_HEADER_COLOR
+import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_FOOTER_COLOR
 import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR
+import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TITLE_COLOR
+import io.legado.app.ui.book.read.config.RegexColorConfigDialog
+import io.legado.app.ui.book.read.config.RegexColorConfigDialog.Companion.REGEX_RULE_COLOR
 import io.legado.app.ui.book.read.config.ToolButtonConfigDialog
 import io.legado.app.ui.book.read.config.UnderlineConfigDialog.Companion.U_COLOR
 import io.legado.app.ui.book.read.page.ContentTextView
@@ -95,6 +99,7 @@ import io.legado.app.ui.book.read.page.ReadView
 import io.legado.app.ui.book.read.page.entities.PageDirection
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.ui.book.read.page.provider.TextChapterLayout
 import io.legado.app.ui.book.read.page.provider.LayoutProgressListener
 import io.legado.app.ui.book.searchContent.SearchContentActivity
 import io.legado.app.ui.book.searchContent.SearchResult
@@ -1698,8 +1703,14 @@ class ReadBookActivity : BaseReadBookActivity(),
                 }
             }
 
-            TIP_COLOR -> {
-                ReadTipConfig.tipColor = color
+            TIP_HEADER_COLOR -> {
+                ReadTipConfig.tipHeaderColor = color
+                postEvent(EventBus.TIP_COLOR, "")
+                postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+            }
+
+            TIP_FOOTER_COLOR -> {
+                ReadTipConfig.tipFooterColor = color
                 postEvent(EventBus.TIP_COLOR, "")
                 postEvent(EventBus.UP_CONFIG, arrayListOf(2))
             }
@@ -1710,6 +1721,24 @@ class ReadBookActivity : BaseReadBookActivity(),
                 postEvent(EventBus.UP_CONFIG, arrayListOf(2))
             }
 
+            TITLE_COLOR -> {
+                ReadBookConfig.titleColor = color
+                postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
+            }
+
+            REGEX_RULE_COLOR -> {
+                val pos = RegexColorConfigDialog.pendingColorPosition
+                if (pos in ReadBookConfig.regexColorRules.indices) {
+                    ReadBookConfig.regexColorRules[pos].color = color
+                    ReadBookConfig.saveRegexColorRules()
+                    TextChapterLayout.invalidateRegexCache()
+                    postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
+                }
+                val fontConfigDialog = supportFragmentManager.findFragmentByTag("FontConfigDialog")
+                (fontConfigDialog?.childFragmentManager?.findFragmentByTag("regexColorConfig") as? RegexColorConfigDialog)
+                    ?.onColorSelected(color)
+            }
+
             B_COLOR -> {
                 setMenuCurBg(color)
                 postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/read/config/FontConfigDialog.kt

@@ -107,6 +107,10 @@ class FontConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_conf
             callBack2?.showShadowSet()
         }
 
+        binding.btnRegexColor.setOnClickListener {
+            RegexColorConfigDialog().show(childFragmentManager, "regexColorConfig")
+        }
+
         binding.btnSelectFonts.setOnClickListener {
             callBack2?.showFontSelect()
         }

+ 22 - 1
app/src/main/java/io/legado/app/ui/book/read/config/FontSelectDialog.kt

@@ -6,6 +6,7 @@ import android.os.Bundle
 import android.view.View
 import android.view.ViewGroup
 import androidx.core.net.toUri
+import androidx.core.widget.addTextChangedListener
 import androidx.documentfile.provider.DocumentFile
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.GridLayoutManager
@@ -50,6 +51,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
         val curFontPath = callBack?.curFontPath ?: ""
         FontAdapter(requireContext(), curFontPath)
     }
+    private var allFontItems: List<FileDoc> = emptyList()
 
     private val selectFontDir = registerForActivityResult(HandleFileContract()) {
         it.uri?.let { uri ->
@@ -76,6 +78,22 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
         binding.recyclerView.layoutManager = GridLayoutManager(context, 2)
         binding.recyclerView.adapter = adapter
         initView()
+        initSearch()
+    }
+
+    private fun initSearch() {
+        binding.etSearch.addTextChangedListener {
+            filterFonts(it.toString())
+        }
+    }
+
+    private fun filterFonts(keyword: String) {
+        val filtered = if (keyword.isBlank()) {
+            allFontItems
+        } else {
+            allFontItems.filter { it.name.contains(keyword, ignoreCase = true) }
+        }
+        adapter.setItems(filtered)
     }
 
     private fun initView() {
@@ -135,6 +153,7 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
             } ?: ArrayList()
             mergeFontItems(fontItems, getLocalFonts())
         }.onSuccess {
+            allFontItems = it
             adapter.setItems(it)
         }.onError {
             AppLog.put("加载字体文件失败\n${it.localizedMessage}", it)
@@ -176,8 +195,10 @@ class FontSelectDialog : BaseBottomSheetDialogFragment(R.layout.dialog_font_sele
         callBack?.selectFont("")
     }
 
+    var explicitCallback: CallBack? = null
+
     private val callBack: CallBack?
-        get() = (parentFragment as? CallBack) ?: (activity as? CallBack)
+        get() = explicitCallback ?: (parentFragment as? CallBack) ?: (activity as? CallBack)
 
     inner class FontAdapter(context: Context, curFilePath: String) :
         RecyclerAdapter<FileDoc, ItemFontBinding>(context) {

+ 57 - 12
app/src/main/java/io/legado/app/ui/book/read/config/InfoConfigDialog.kt

@@ -11,23 +11,32 @@ import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ReadTipConfig
 import io.legado.app.lib.dialogs.selector
 import io.legado.app.ui.book.read.ReadBookActivity
-import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_COLOR
 import io.legado.app.ui.book.read.config.TipConfigDialog.Companion.TIP_DIVIDER_COLOR
 import io.legado.app.utils.getCompatColor
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 
-class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info) {
+class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info), FontSelectDialog.CallBack {
 
     private val binding by viewBinding(DialogReadInfoBinding::bind)
     private val callBack get() = activity as? ReadBookActivity
 
+    override val curFontPath: String
+        get() = ReadBookConfig.headerFont
+
+    override fun selectFont(path: String) {
+        ReadBookConfig.headerFont = path
+        ReadBookConfig.save()
+        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+    }
+
     override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
         initView()
         initEvent()
         observeEvent<String>(EventBus.TIP_COLOR) {
-            upTvTipColor()
+            upTvHeaderColor()
+            upTvFooterColor()
             upTvTipDividerColor()
         }
         observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) { list ->
@@ -59,17 +68,28 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info
             callBack?.showPaddingConfig()
             dismissAllowingStateLoss()
         }
-        upTvTipColor()
+        upTvHeaderColor()
+        upTvFooterColor()
         upTvTipDividerColor()
+        binding.scvHeaderFontSize.progress = ReadBookConfig.headerFontSize
+    }
+
+    private fun upTvHeaderColor() {
+        val tipColor = if (ReadTipConfig.tipHeaderColor == 0) {
+            ReadBookConfig.textColor
+        } else {
+            ReadTipConfig.tipHeaderColor
+        }
+        binding.btnHeaderColor.color = tipColor
     }
 
-    private fun upTvTipColor() {
-        val tipColor = if (ReadTipConfig.tipColor == 0) {
+    private fun upTvFooterColor() {
+        val tipColor = if (ReadTipConfig.tipFooterColor == 0) {
             ReadBookConfig.textColor
         } else {
-            ReadTipConfig.tipColor
+            ReadTipConfig.tipFooterColor
         }
-        binding.btnTipColor.color = tipColor
+        binding.btnFooterColor.color = tipColor
     }
 
     private fun upTvTipDividerColor() {
@@ -171,19 +191,36 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info
                 postEvent(EventBus.UP_CONFIG, arrayListOf(2, 6))
             }
         }
-        btnTipColor.setOnClickListener {
+        btnHeaderColor.setOnClickListener {
             context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
                 when (i) {
                     0 -> {
-                        ReadTipConfig.tipColor = 0
-                        upTvTipColor()
+                        ReadTipConfig.tipHeaderColor = 0
+                        upTvHeaderColor()
                         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
                     }
 
                     1 -> ColorPickerDialog.newBuilder()
                         .setShowAlphaSlider(false)
                         .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
-                        .setDialogId(TIP_COLOR)
+                        .setDialogId(TipConfigDialog.TIP_HEADER_COLOR)
+                        .show(requireActivity())
+                }
+            }
+        }
+        btnFooterColor.setOnClickListener {
+            context?.selector(items = ReadTipConfig.tipColorNames) { _, i ->
+                when (i) {
+                    0 -> {
+                        ReadTipConfig.tipFooterColor = 0
+                        upTvFooterColor()
+                        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+                    }
+
+                    1 -> ColorPickerDialog.newBuilder()
+                        .setShowAlphaSlider(false)
+                        .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
+                        .setDialogId(TipConfigDialog.TIP_FOOTER_COLOR)
                         .show(requireActivity())
                 }
             }
@@ -205,6 +242,14 @@ class InfoConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_read_info
                 }
             }
         }
+        btnHeaderFont.setOnClickListener {
+            FontSelectDialog().show(childFragmentManager, "headerFontSelect")
+        }
+        binding.scvHeaderFontSize.onChanged = {
+            ReadBookConfig.headerFontSize = it
+            ReadBookConfig.save()
+            postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+        }
     }
 
     private fun clearRepeat(repeat: Int) = ReadTipConfig.apply {

+ 204 - 0
app/src/main/java/io/legado/app/ui/book/read/config/RegexColorConfigDialog.kt

@@ -0,0 +1,204 @@
+package io.legado.app.ui.book.read.config
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import io.legado.app.R
+import io.legado.app.base.BaseBottomSheetDialogFragment
+import io.legado.app.constant.EventBus
+import io.legado.app.databinding.DialogRegexColorConfigBinding
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.help.config.RegexColorRule
+import io.legado.app.lib.dialogs.alert
+import io.legado.app.ui.book.read.ReadBookActivity
+import io.legado.app.ui.book.read.page.provider.TextChapterLayout
+import io.legado.app.ui.widget.AccentColorButton
+import io.legado.app.utils.postEvent
+import io.legado.app.utils.viewbindingdelegate.viewBinding
+import com.jaredrummler.android.colorpicker.ColorPickerDialog
+
+class RegexColorConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_regex_color_config),
+    FontSelectDialog.CallBack {
+
+    private val binding by viewBinding(DialogRegexColorConfigBinding::bind)
+    private val callBack2 get() = activity as? ReadBookActivity
+    private lateinit var adapter: RegexColorRuleAdapter
+    private var editingRulePosition = -1
+
+    companion object {
+        const val REGEX_RULE_COLOR = 7900
+        var pendingColorPosition = -1
+    }
+
+    override val curFontPath: String
+        get() = if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
+            ReadBookConfig.regexColorRules[editingRulePosition].fontPath
+        } else ""
+
+    override fun selectFont(path: String) {
+        if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
+            ReadBookConfig.regexColorRules[editingRulePosition].fontPath = path
+            notifyConfigChanged()
+        }
+    }
+
+    override fun onFragmentCreated(view: View, savedInstanceState: Bundle?) {
+        adapter = RegexColorRuleAdapter(
+            onDeleteClick = { position -> deleteRule(position) },
+            onColorClick = { position -> showColorPicker(position) },
+            onFontClick = { position -> showFontSelect(position) }
+        )
+        initView()
+        initViewEvent()
+    }
+
+    private fun initView() = binding.run {
+        recyclerView.layoutManager = LinearLayoutManager(context)
+        recyclerView.adapter = adapter
+        adapter.setItems(ReadBookConfig.regexColorRules)
+    }
+
+    private fun initViewEvent() = binding.run {
+        btnAddRule.setOnClickListener {
+            showAddRuleDialog()
+        }
+    }
+
+    private fun showAddRuleDialog() {
+        val defaultPatterns = listOf(
+            "\u201C匹配内容\u201D" to "\u201C.+?\u201D",
+            "《匹配内容》" to "《.+?》",
+            "\"匹配内容\"" to "\".+?\""
+        )
+        val displayItems = defaultPatterns.map { it.first } + "自定义规则"
+        context?.alert(title = "添加正则规则") {
+            items(displayItems) { _, i ->
+                if (i < defaultPatterns.size) {
+                    val (name, pattern) = defaultPatterns[i]
+                    addRule(name, pattern)
+                } else {
+                    showCustomRuleDialog()
+                }
+            }
+        }
+    }
+
+    private fun showCustomRuleDialog() {
+        val editText = android.widget.EditText(context).apply {
+            hint = "输入正则表达式,如:\\u201C.+?\\u201D"
+        }
+        context?.alert(title = "自定义正则规则") {
+            customView { editText }
+            okButton {
+                val pattern = editText.text.toString().trim()
+                if (pattern.isNotEmpty()) {
+                    addRule(pattern, pattern)
+                }
+            }
+            cancelButton()
+        }
+    }
+
+    private fun addRule(name: String, pattern: String) {
+        val rule = RegexColorRule(name, pattern, ReadBookConfig.durConfig.curTextAccentColor())
+        ReadBookConfig.regexColorRules.add(rule)
+        notifyConfigChanged()
+    }
+
+    private fun deleteRule(position: Int) {
+        if (position >= 0 && position < ReadBookConfig.regexColorRules.size) {
+            ReadBookConfig.regexColorRules.removeAt(position)
+            notifyConfigChanged()
+        }
+    }
+
+    private fun showColorPicker(position: Int) {
+        if (position !in ReadBookConfig.regexColorRules.indices) return
+        editingRulePosition = position
+        pendingColorPosition = position
+        val rule = ReadBookConfig.regexColorRules[position]
+        val colorValue = rule.color or 0xFF000000.toInt()
+        ColorPickerDialog.newBuilder()
+            .setColor(colorValue)
+            .setShowAlphaSlider(false)
+            .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
+            .setDialogId(REGEX_RULE_COLOR)
+            .show(requireActivity())
+    }
+
+    private fun showFontSelect(position: Int) {
+        if (position !in ReadBookConfig.regexColorRules.indices) return
+        editingRulePosition = position
+        FontSelectDialog().apply {
+            explicitCallback = this@RegexColorConfigDialog
+        }.show(childFragmentManager, "regexFontSelect")
+    }
+
+    fun onColorSelected(color: Int) {
+        if (editingRulePosition in ReadBookConfig.regexColorRules.indices) {
+            ReadBookConfig.regexColorRules[editingRulePosition].color = color
+            notifyConfigChanged()
+        }
+    }
+
+    private fun notifyConfigChanged() {
+        ReadBookConfig.saveRegexColorRules()
+        TextChapterLayout.invalidateRegexCache()
+        adapter.setItems(ReadBookConfig.regexColorRules)
+        postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
+    }
+}
+
+class RegexColorRuleAdapter(
+    private val onDeleteClick: ((Int) -> Unit)? = null,
+    private val onColorClick: ((Int) -> Unit)? = null,
+    private val onFontClick: ((Int) -> Unit)? = null
+) : RecyclerView.Adapter<RegexColorRuleAdapter.ViewHolder>() {
+
+    private var items: List<RegexColorRule> = emptyList()
+
+    fun setItems(items: List<RegexColorRule>) {
+        this.items = items
+        notifyDataSetChanged()
+    }
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
+        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_regex_color_rule, parent, false)
+        return ViewHolder(view)
+    }
+
+    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
+        val item = items[position]
+        holder.bind(item, position)
+    }
+
+    override fun getItemCount(): Int {
+        return items.size
+    }
+
+    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+        val tvRuleName = itemView.findViewById<android.widget.TextView>(R.id.tv_rule_name)
+        val tvRulePattern = itemView.findViewById<android.widget.TextView>(R.id.tv_rule_pattern)
+        val btnSelectFont = itemView.findViewById<com.google.android.material.button.MaterialButton>(R.id.btn_select_font)
+        val btnSelectColor = itemView.findViewById<AccentColorButton>(R.id.btn_select_color)
+        val btnDelete = itemView.findViewById<android.widget.ImageButton>(R.id.btn_delete)
+
+        fun bind(item: RegexColorRule, position: Int) {
+            tvRuleName.text = item.name
+            tvRulePattern.text = item.pattern
+            btnSelectColor.color = item.color or 0xFF000000.toInt()
+            btnSelectFont.setOnClickListener {
+                onFontClick?.invoke(position)
+            }
+            btnSelectColor.setOnClickListener {
+                onColorClick?.invoke(position)
+            }
+            btnDelete.setOnClickListener {
+                onDeleteClick?.invoke(position)
+            }
+        }
+    }
+}

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

@@ -19,17 +19,27 @@ import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 
 
-class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config) {
+class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config), FontSelectDialog.CallBack {
 
     companion object {
-        const val TIP_COLOR = 7897
+        const val TIP_HEADER_COLOR = 7897
+        const val TIP_FOOTER_COLOR = 7899
         const val TIP_DIVIDER_COLOR = 7898
+        const val TITLE_COLOR = 7896
         const val B_COLOR = 114
         const val A_COLOR = 514
     }
 
     private val binding by viewBinding(DialogTipConfigBinding::bind)
 
+    override val curFontPath: String
+        get() = ReadBookConfig.titleFont
+
+    override fun selectFont(path: String) {
+        ReadBookConfig.titleFont = path
+        postEvent(EventBus.UP_CONFIG, arrayListOf(8, 5))
+    }
+
     override fun onStart() {
         super.onStart()
     }
@@ -41,6 +51,10 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config
             binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg()
             binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc()
         }
+        observeEvent<ArrayList<Int>>(EventBus.UP_CONFIG) {
+            val preview = if (ReadBookConfig.titleColor != 0) ReadBookConfig.titleColor else ReadBookConfig.textColor
+            binding.abtnTitleColor.color = preview or 0xFF000000.toInt()
+        }
     }
 
     private fun initView() {
@@ -53,16 +67,40 @@ class TipConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_tip_config
         }
         val weightOptions = context?.resources?.getStringArray(R.array.text_font_weight)
         val weightValues = listOf(0, 1, 2)
+        val initialWeightIndex = weightValues.indexOf(ReadBookConfig.titleBold)
+        val weightIconMap = mapOf(
+            0 to R.drawable.ic_text_weight_0,
+            1 to R.drawable.ic_text_weight_1,
+            2 to R.drawable.ic_text_weight_2,
+        )
+        val initialIconRes = weightIconMap[initialWeightIndex] ?: R.drawable.ic_text_weight_2
+        binding.textFontWeightConverter.setIconResource(initialIconRes)
         binding.textFontWeightConverter.setOnClickListener {
             context?.alert(titleResource = R.string.text_font_weight_converter) {
                 weightOptions?.let { options ->
                     items(options.toList()) { _, i ->
                         ReadBookConfig.titleBold = weightValues[i]
+                        val iconRes = weightIconMap[i] ?: R.drawable.ic_text_weight_2
+                        binding.textFontWeightConverter.setIconResource(iconRes)
                         postEvent(EventBus.UP_CONFIG, arrayListOf(8, 9, 6))
                     }
                 }
             }
         }
+        binding.btnSelectTitleFont.setOnClickListener {
+            FontSelectDialog().show(childFragmentManager, "fontSelect")
+        }
+        val titleColorValue = ReadBookConfig.titleColor
+        val titleColorPreview = if (titleColorValue != 0) titleColorValue else ReadBookConfig.textColor
+        binding.abtnTitleColor.color = titleColorPreview or 0xFF000000.toInt()
+        binding.abtnTitleColor.setOnClickListener {
+            ColorPickerDialog.newBuilder()
+                .setColor(titleColorPreview or 0xFF000000.toInt())
+                .setShowAlphaSlider(false)
+                .setDialogType(ColorPickerDialog.TYPE_CUSTOM)
+                .setDialogId(TITLE_COLOR)
+                .show(requireActivity())
+        }
         binding.abtnBackgroundColor.color = ReadBookConfig.durConfig.curMenuBg()
         binding.abtnAccentColor.color = ReadBookConfig.durConfig.curMenuAc()
         binding.abtnBackgroundColor.setOnClickListener {

+ 7 - 0
app/src/main/java/io/legado/app/ui/book/read/config/UnderlineConfigDialog.kt

@@ -10,6 +10,7 @@ import io.legado.app.databinding.DialogUnderlineConfigBinding
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.config.ReadBookConfig.dottedLine
 import io.legado.app.help.config.ReadBookConfig.underline
+import io.legado.app.help.config.ReadBookConfig.underlineExtend
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.viewbindingdelegate.viewBinding
@@ -39,6 +40,7 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde
         binding.swUnderline.isChecked = underline
         binding.swDottedline.isChecked = dottedLine
         binding.swDottedline.isEnabled = underline
+        binding.swUnderlineExtend.isChecked = underlineExtend
 
         binding.swUnderline.addOnCheckedChangeListener { _, isChecked ->
             underline = isChecked
@@ -55,6 +57,11 @@ class UnderlineConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_unde
             postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11))
         }
 
+        binding.swUnderlineExtend.addOnCheckedChangeListener { _, isChecked ->
+            underlineExtend = isChecked
+            postEvent(EventBus.UP_CONFIG, arrayListOf(6, 9, 11))
+        }
+
         binding.btnDottedLineBlack.apply {
             progress = ReadBookConfig.durConfig.dottedBase.toInt()
             onChanged = {

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

@@ -8,6 +8,7 @@ import android.view.LayoutInflater
 import android.widget.FrameLayout
 import androidx.core.content.ContextCompat
 import androidx.core.graphics.drawable.toDrawable
+import androidx.core.net.toUri
 import androidx.core.view.ViewCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.isGone
@@ -30,9 +31,11 @@ import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.applyStatusBarPadding
 import io.legado.app.utils.dpToPx
 import io.legado.app.utils.gone
+import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.statusBarHeight
 import splitties.views.backgroundColor
+import java.io.File
 import java.util.Date
 
 /**
@@ -91,8 +94,11 @@ class PageView(context: Context) : FrameLayout(context) {
         upTipStyle()
         ReadBookConfig.let {
             val textColor = it.textColor
-            val tipColor = with(ReadTipConfig) {
-                if (tipColor == 0) textColor else tipColor
+            val headerColor = with(ReadTipConfig) {
+                if (tipHeaderColor == 0) textColor else tipHeaderColor
+            }
+            val footerColor = with(ReadTipConfig) {
+                if (tipFooterColor == 0) textColor else tipFooterColor
             }
             val tipDividerColor = with(ReadTipConfig) {
                 when (tipDividerColor) {
@@ -101,12 +107,12 @@ class PageView(context: Context) : FrameLayout(context) {
                     else -> tipDividerColor
                 }
             }
-            tvHeaderLeft.setColor(tipColor)
-            tvHeaderMiddle.setColor(tipColor)
-            tvHeaderRight.setColor(tipColor)
-            tvFooterLeft.setColor(tipColor)
-            tvFooterMiddle.setColor(tipColor)
-            tvFooterRight.setColor(tipColor)
+            tvHeaderLeft.setColor(headerColor)
+            tvHeaderMiddle.setColor(headerColor)
+            tvHeaderRight.setColor(headerColor)
+            tvFooterLeft.setColor(footerColor)
+            tvFooterMiddle.setColor(footerColor)
+            tvFooterRight.setColor(footerColor)
             vwTopDivider.backgroundColor = tipDividerColor
             vwBottomDivider.backgroundColor = tipDividerColor
             upStatusBar()
@@ -131,6 +137,24 @@ class PageView(context: Context) : FrameLayout(context) {
         upBattery(battery)
     }
 
+    private fun loadTypeface(fontPath: String): Typeface? {
+        return runCatching {
+            when {
+                fontPath.isContentScheme() -> {
+                    context.contentResolver
+                        .openFileDescriptor(fontPath.toUri(), "r")
+                        ?.use {
+                            Typeface.Builder(it.fileDescriptor).build()
+                        }
+                }
+                fontPath.isNotEmpty() -> {
+                    Typeface.Builder(File(fontPath)).build()
+                }
+                else -> null
+            }
+        }.getOrNull()
+    }
+
     /**
      * 显示状态栏时隐藏header
      */
@@ -208,106 +232,108 @@ class PageView(context: Context) : FrameLayout(context) {
                 tvFooterRight.batteryMode = BatteryView.BatteryMode.NO_BATTERY
             }
         }
+        val tipTypeface = loadTypeface(ReadBookConfig.headerFont) ?: ChapterProvider.typeface
+        val tipTextSize = ReadBookConfig.headerFontSize.toFloat()
         tvTitle = getTipView(ReadTipConfig.chapterTitle)?.apply {
             tag = ReadTipConfig.chapterTitle
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
         tvTitleArrow = getTipView(ReadTipConfig.chapterTitleArrow)?.apply {
             tag = ReadTipConfig.chapterTitleArrow
             typeface = Typeface.DEFAULT
-            textSize = 12f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
         tvTitleArrowClassic = getTipView(ReadTipConfig.chapterTitleArrowClassic)?.apply {
             tag = ReadTipConfig.chapterTitleArrowClassic
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ARROW
         }
         tvTime = getTipView(ReadTipConfig.time)?.apply {
             tag = ReadTipConfig.time
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
         tvBattery = getTipView(ReadTipConfig.battery)?.apply {
             tag = ReadTipConfig.battery
             typeface = Typeface.DEFAULT
-            textSize = 11f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.OUTER
         }
         tvBatteryClassic = getTipView(ReadTipConfig.batteryClassic)?.apply {
             tag = ReadTipConfig.batteryClassic
-            textSize = 11f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
         tvBatteryInside = getTipView(ReadTipConfig.batteryInside)?.apply {
             tag = ReadTipConfig.batteryInside
             typeface = Typeface.DEFAULT
-            textSize = 11f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.INNER
         }
         tvBatteryIcon = getTipView(ReadTipConfig.batteryIcon)?.apply {
             tag = ReadTipConfig.batteryIcon
             typeface = Typeface.DEFAULT
-            textSize = 11f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.ICON
         }
         tvPage = getTipView(ReadTipConfig.page)?.apply {
             tag = ReadTipConfig.page
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
         }
         tvTotalProgress = getTipView(ReadTipConfig.totalProgress)?.apply {
             tag = ReadTipConfig.totalProgress
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
         tvTotalProgress1 = getTipView(ReadTipConfig.totalProgress1)?.apply {
             tag = ReadTipConfig.totalProgress1
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
         tvPageAndTotal = getTipView(ReadTipConfig.pageAndTotal)?.apply {
             tag = ReadTipConfig.pageAndTotal
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
         tvBookName = getTipView(ReadTipConfig.bookName)?.apply {
             tag = ReadTipConfig.bookName
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
         tvTimeBattery = getTipView(ReadTipConfig.timeBattery)?.apply {
             tag = ReadTipConfig.timeBattery
             typeface = Typeface.DEFAULT
-            textSize = 11f
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.TIME
         }
         tvTimeBatteryClassic = getTipView(ReadTipConfig.timeBatteryClassic)?.apply {
             tag = ReadTipConfig.timeBatteryClassic
-            typeface = ChapterProvider.typeface
-            textSize = 11f
+            typeface = tipTypeface
+            textSize = tipTextSize
             batteryMode = BatteryView.BatteryMode.CLASSIC
         }
         tvBatteryP = getTipView(ReadTipConfig.batteryPercentage)?.apply {
             tag = ReadTipConfig.batteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
         tvTimeBatteryP = getTipView(ReadTipConfig.timeBatteryPercentage)?.apply {
             tag = ReadTipConfig.timeBatteryPercentage
             batteryMode = BatteryView.BatteryMode.NO_BATTERY
-            typeface = ChapterProvider.typeface
-            textSize = 12f
+            typeface = tipTypeface
+            textSize = tipTextSize
         }
     }
 

+ 15 - 1
app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt

@@ -73,6 +73,8 @@ data class TextLine(
     fun addColumn(column: BaseColumn) {
         if (column !is TextColumn) {
             onlyTextColumn = false
+        } else if (column.color != null) {
+            onlyTextColumn = false
         }
         column.textLine = this
         textColumns.add(column)
@@ -189,6 +191,8 @@ data class TextLine(
         }
         val textColor = if (isReadAloud) {
             ReadBookConfig.textAccentColor
+        } else if (isTitle && ReadBookConfig.titleColor != 0) {
+            ReadBookConfig.titleColor
         } else {
             ReadBookConfig.textColor
         }
@@ -229,7 +233,17 @@ data class TextLine(
             null
 
         val lineY = height + (ReadBookConfig.durConfig.underlinePadding - 10).dpToPx()
-        canvas.drawLine(lineStart + indentWidth, lineY, lineEnd, lineY, paint)
+        val startX = if (ReadBookConfig.underlineExtend) {
+            ChapterProvider.paddingLeft.toFloat()
+        } else {
+            lineStart + indentWidth
+        }
+        val endX = if (ReadBookConfig.underlineExtend) {
+            (ChapterProvider.paddingLeft + ChapterProvider.visibleWidth).toFloat()
+        } else {
+            lineEnd
+        }
+        canvas.drawLine(startX, lineY, endX, lineY, paint)
     }
 
 

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

@@ -368,9 +368,14 @@ data class TextPage(
     }
 
     fun upRenderHeight() {
-        renderHeight = ceil(lines.last().lineBottom).toInt()
+        val underlineExtraHeight = if (ReadBookConfig.underline) {
+            (ReadBookConfig.durConfig.underlinePadding + ReadBookConfig.underlineHeight).dpToPx().toInt()
+        } else {
+            0
+        }
+        renderHeight = ceil(lines.last().lineBottom).toInt() + underlineExtraHeight
         if (leftLineSize > 0 && leftLineSize != lines.size) {
-            val leftHeight = ceil(lines[leftLineSize - 1].lineBottom).toInt()
+            val leftHeight = ceil(lines[leftLineSize - 1].lineBottom).toInt() + underlineExtraHeight
             renderHeight = max(renderHeight, leftHeight)
         }
     }

+ 64 - 13
app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt

@@ -1,22 +1,26 @@
 package io.legado.app.ui.book.read.page.entities.column
 
 import android.graphics.Canvas
+import android.graphics.Typeface
 import android.os.Build
 import androidx.annotation.Keep
+import androidx.core.net.toUri
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.entities.TextLine
 import io.legado.app.ui.book.read.page.entities.TextLine.Companion.emptyTextLine
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
+import io.legado.app.utils.isContentScheme
+import splitties.init.appCtx
+import java.io.File
 
-/**
- * 文字列
- */
 @Keep
 data class TextColumn(
     override var start: Float,
     override var end: Float,
     override val charData: String,
+    var color: Int? = null,
+    var fontPath: String? = null,
 ) : TextBaseColumn {
 
     override var textLine: TextLine = emptyTextLine
@@ -47,19 +51,44 @@ data class TextColumn(
         } else {
             ChapterProvider.contentPaint
         }
-        val textColor = if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) {
+        val textColor = color ?: if (!textLine.useUnderline && (textLine.isReadAloud || isSearchResult)) {
             ReadBookConfig.textAccentColor
+        } else if (textLine.isTitle && ReadBookConfig.titleColor != 0) {
+            ReadBookConfig.titleColor
         } else {
             ReadBookConfig.textColor
         }
-        val originalSize = textPaint.textSize
-        textLine.titleTextSize?.let {
-            textPaint.textSize = it
+        val needRestoreSize = textLine.titleTextSize != null
+        val needRestoreColor = textPaint.color != textColor
+        val customTypeface = fontPath?.let { getTypeface(it) }
+        val needRestoreTypeface = customTypeface != null
+        if (needRestoreSize) {
+            val originalSize = textPaint.textSize
+            textPaint.textSize = textLine.titleTextSize!!
+            if (needRestoreColor) textPaint.color = textColor
+            if (needRestoreTypeface) textPaint.typeface = customTypeface
+            val y = textLine.lineBase - textLine.lineTop
+            drawText(canvas, y, textPaint)
+            textPaint.textSize = originalSize
+        } else if (needRestoreColor || needRestoreTypeface) {
+            val originalColor = textPaint.color
+            val originalTypeface = textPaint.typeface
+            if (needRestoreColor) textPaint.color = textColor
+            if (needRestoreTypeface) textPaint.typeface = customTypeface
+            val y = textLine.lineBase - textLine.lineTop
+            drawText(canvas, y, textPaint)
+            if (needRestoreColor) textPaint.color = originalColor
+            if (needRestoreTypeface) textPaint.typeface = originalTypeface
+        } else {
+            val y = textLine.lineBase - textLine.lineTop
+            drawText(canvas, y, textPaint)
         }
-        if (textPaint.color != textColor) {
-            textPaint.color = textColor
+        if (selected) {
+            canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
         }
-        val y = textLine.lineBase - textLine.lineTop
+    }
+
+    private fun drawText(canvas: Canvas, y: Float, textPaint: android.text.TextPaint) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
             val letterSpacing = textPaint.letterSpacing * textPaint.textSize
             val letterSpacingHalf = letterSpacing * 0.5f
@@ -67,10 +96,32 @@ data class TextColumn(
         } else {
             canvas.drawText(charData, start, y, textPaint)
         }
-        if (selected) {
-            canvas.drawRect(start, 0f, end, textLine.height, view.selectedPaint)
+    }
+
+    companion object {
+        private val typefaceCache = mutableMapOf<String, Typeface?>()
+
+        private fun getTypeface(fontPath: String): Typeface? {
+            return typefaceCache.getOrPut(fontPath) {
+                kotlin.runCatching {
+                    when {
+                        fontPath.isContentScheme() -> {
+                            appCtx.contentResolver
+                                .openFileDescriptor(fontPath.toUri(), "r")!!
+                                .use {
+                                    Typeface.Builder(it.fileDescriptor).build()
+                                }
+                        }
+
+                        fontPath.isNotEmpty() -> {
+                            Typeface.Builder(File(fontPath)).build()
+                        }
+
+                        else -> null
+                    }
+                }.getOrNull()
+            }
         }
-        textPaint.textSize = originalSize
     }
 
 }

+ 30 - 11
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -934,30 +934,49 @@ object ChapterProvider {
 
     @SuppressLint("UseKtx")
     private fun getPaints(typeface: Typeface?): Pair<TextPaint, TextPaint> {
+        val titleTypeface = runCatching {
+            val titleFontPath = ReadBookConfig.titleFont
+            when {
+                titleFontPath.isContentScheme() -> {
+                    appCtx.contentResolver
+                        .openFileDescriptor(titleFontPath.toUri(), "r")!!
+                        .use {
+                            Typeface.Builder(it.fileDescriptor).build()
+                        }
+                }
+                titleFontPath.isNotEmpty() -> {
+                    Typeface.Builder(File(titleFontPath)).build()
+                }
+                else -> typeface
+            }
+        }.getOrNull() ?: typeface
+
         val bold = Typeface.create(typeface, Typeface.BOLD)
         val normal = Typeface.create(typeface, Typeface.NORMAL)
-        val titleFont = when (ReadBookConfig.titleBold) {
+        val titleBoldTypeface = Typeface.create(titleTypeface, Typeface.BOLD)
+        val titleNormalTypeface = Typeface.create(titleTypeface, Typeface.NORMAL)
+        val titleFontTypeface = when (ReadBookConfig.titleBold) {
             1 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
-                Typeface.create(typeface, 900, false)
+                Typeface.create(titleTypeface, 900, false)
             else
-                bold
+                titleBoldTypeface
 
             2 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
-                Typeface.create(typeface, 300, false)
+                Typeface.create(titleTypeface, 300, false)
             else
-                normal
+                titleNormalTypeface
 
             0 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
-                Typeface.create(typeface, 400, false)
+                Typeface.create(titleTypeface, 400, false)
             else
-                normal
+                titleNormalTypeface
 
             in 100..900 -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
-                Typeface.create(typeface, ReadBookConfig.titleBold, false)
+                Typeface.create(titleTypeface, ReadBookConfig.titleBold, false)
             else
-                normal
+                titleNormalTypeface
 
-            else -> normal
+            else -> titleNormalTypeface
         }
 
         val textFont = when (ReadBookConfig.textBold) {
@@ -989,7 +1008,7 @@ object ChapterProvider {
         val tPaint = TextPaint()
         tPaint.color = ReadBookConfig.textColor
         tPaint.letterSpacing = ReadBookConfig.letterSpacing
-        tPaint.typeface = titleFont
+        tPaint.typeface = titleFontTypeface
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ReadBookConfig.titleBold in 100..900)
             tPaint.setFontVariationSettings("'wght' ${ReadBookConfig.titleBold}")
         tPaint.textSize = with(ReadBookConfig) { textSize + titleSize }.toFloat().spToPx()

+ 115 - 25
app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt

@@ -69,6 +69,14 @@ class TextChapterLayout(
     private val bookContent: BookContent,
 ) {
 
+    companion object {
+        private val regexCache = mutableMapOf<String, Regex>()
+
+        fun invalidateRegexCache() {
+            regexCache.clear()
+        }
+    }
+
     @Volatile
     private var listener: LayoutProgressListener? = textChapter
 
@@ -864,6 +872,7 @@ class TextChapterLayout(
     ) {
         val widthsArray = allocateFloatArray(text.length)
         textPaint.getTextWidthsCompat(text, widthsArray)
+        val colorMap = applyRegexColorRules(text)
         val layout = if (useZhLayout) {
             val (words, widths) = measureTextSplit(text, widthsArray)
             val indentSize = if (isFirstLine) paragraphIndent.length else 0
@@ -915,12 +924,15 @@ class TextChapterLayout(
             val (words, widths) = measureTextSplit(lineText, widthsArray, lineStart)
             val desiredWidth = widths.fastSum()
             textLine.text = lineText
+            val lineWordStyles = if (colorMap != null) {
+                buildWordStyles(words, lineText, colorMap.colorArray, colorMap.fontPathArray, lineStart)
+            } else null
             when (lineIndex) {
                 0 if layout.lineCount > 1 && !isTitle && isFirstLine -> {
                     //多行的第一行 非标题
                     addCharsToLineFirst(
                         book, absStartX, textLine, words, textPaint,
-                        desiredWidth, widths, srcList, clickList
+                        desiredWidth, widths, srcList, clickList, lineWordStyles
                     )
                 }
 
@@ -938,7 +950,7 @@ class TextChapterLayout(
                     }
                     addCharsToLineNatural(
                         book, absStartX, textLine, words,
-                        startX, !isTitle && lineIndex == 0, widths, srcList, clickList
+                        startX, !isTitle && lineIndex == 0, widths, srcList, clickList, lineWordStyles
                     )
                 }
                 else -> {
@@ -951,13 +963,13 @@ class TextChapterLayout(
                         val startX = (visibleWidth - desiredWidth) / 2
                         addCharsToLineNatural(
                             book, absStartX, textLine, words,
-                            startX, false, widths, srcList, clickList
+                            startX, false, widths, srcList, clickList, lineWordStyles
                         )
                     } else {
                         //中间行
                         addCharsToLineMiddle(
                             book, absStartX, textLine, words, textPaint,
-                            desiredWidth, 0f, widths, srcList, clickList
+                            desiredWidth, 0f, widths, srcList, clickList, lineWordStyles
                         )
                     }
                 }
@@ -1007,17 +1019,17 @@ class TextChapterLayout(
         textLine: TextLine,
         words: List<String>,
         textPaint: TextPaint,
-        /**自然排版长度**/
         desiredWidth: Float,
         textWidths: List<Float>,
         srcList: LinkedList<String>?,
-        clickList: LinkedList<String?>?
+        clickList: LinkedList<String?>?,
+        wordStyles: List<WordStyle>? = null
     ) {
         var x = 0f
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                x, true, textWidths, srcList, clickList
+                x, true, textWidths, srcList, clickList, wordStyles
             )
             return
         }
@@ -1038,9 +1050,10 @@ class TextChapterLayout(
         if (words.size > bodyIndent.length) {
             val text1 = words.subList(bodyIndent.length, words.size)
             val textWidths1 = textWidths.subList(bodyIndent.length, textWidths.size)
+            val wordStyles1 = wordStyles?.subList(bodyIndent.length, wordStyles.size)
             addCharsToLineMiddle(
                 book, absStartX, textLine, text1, textPaint,
-                desiredWidth, x, textWidths1, srcList, clickList
+                desiredWidth, x, textWidths1, srcList, clickList, wordStyles1
             )
         }
     }
@@ -1054,18 +1067,17 @@ class TextChapterLayout(
         textLine: TextLine,
         words: List<String>,
         textPaint: TextPaint,
-        /**自然排版长度**/
         desiredWidth: Float,
-        /**起始x坐标**/
         startX: Float,
         textWidths: List<Float>,
         srcList: LinkedList<String>?,
-        clickList: LinkedList<String?>?
+        clickList: LinkedList<String?>?,
+        wordStyles: List<WordStyle>? = null
     ) {
         if (!textFullJustify) {
             addCharsToLineNatural(
                 book, absStartX, textLine, words,
-                startX, false, textWidths, srcList, clickList
+                startX, false, textWidths, srcList, clickList, wordStyles
             )
             return
         }
@@ -1086,7 +1098,9 @@ class TextChapterLayout(
                 }
                 addCharToLine(
                     book, absStartX, textLine, char,
-                    x, x1, index + 1 == words.size, srcList, clickList
+                    x, x1, index + 1 == words.size, srcList, clickList,
+                    wordStyles?.getOrNull(index)?.color,
+                    wordStyles?.getOrNull(index)?.fontPath
                 )
                 x = x1
             }
@@ -1102,7 +1116,9 @@ class TextChapterLayout(
                 val x1 = if (index != words.lastIndex) (x + cw + d) else (x + cw)
                 addCharToLine(
                     book, absStartX, textLine, char,
-                    x, x1, index + 1 == words.size, srcList, clickList
+                    x, x1, index + 1 == words.size, srcList, clickList,
+                    wordStyles?.getOrNull(index)?.color,
+                    wordStyles?.getOrNull(index)?.fontPath
                 )
                 x = x1
             }
@@ -1122,7 +1138,8 @@ class TextChapterLayout(
         hasIndent: Boolean,
         textWidths: List<Float>,
         srcList: LinkedList<String>?,
-        clickList: LinkedList<String?>?
+        clickList: LinkedList<String?>?,
+        wordStyles: List<WordStyle>? = null
     ) {
         val indentLength = paragraphIndent.length
         var x = startX
@@ -1140,7 +1157,9 @@ class TextChapterLayout(
                 x1,
                 index + 1 == words.size,
                 srcList,
-                clickList
+                clickList,
+                wordStyles?.getOrNull(index)?.color,
+                wordStyles?.getOrNull(index)?.fontPath
             )
             x = x1
             if (hasIndent && index == indentLength - 1) {
@@ -1162,7 +1181,9 @@ class TextChapterLayout(
         xEnd: Float,
         isLineEnd: Boolean,
         srcList: LinkedList<String>?,
-        clickList: LinkedList<String?>?
+        clickList: LinkedList<String?>?,
+        color: Int? = null,
+        fontPath: String? = null
     ) {
         val column = when {
             !srcList.isNullOrEmpty() && (char == srcReplaceChar || char == reviewChar) -> {
@@ -1176,19 +1197,14 @@ class TextChapterLayout(
                     click = click
                 )
             }
-//            isLineEnd && char == ChapterProvider.reviewChar -> {
-//                ReviewColumn(
-//                    start = absStartX + xStart,
-//                    end = absStartX + xEnd,
-//                    count = 10
-//                )
-//            }
 
             else -> {
                 TextColumn(
                     start = absStartX + xStart,
                     end = absStartX + xEnd,
-                    charData = char
+                    charData = char,
+                    color = color,
+                    fontPath = fontPath
                 )
             }
         }
@@ -1289,4 +1305,78 @@ class TextChapterLayout(
         return code == 8203 || code == 8204 || code == 8205 || code == 8288
     }
 
+    private data class RegexMatchResult(
+        val colorArray: IntArray,
+        val fontPathArray: Array<String?>
+    )
+
+    private fun applyRegexColorRules(text: String): RegexMatchResult? {
+        val rules = ReadBookConfig.regexColorRules
+        if (rules.isEmpty()) return null
+        var hasMatch = false
+        for (rule in rules) {
+            try {
+                val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) }
+                if (regex.containsMatchIn(text)) {
+                    hasMatch = true
+                    break
+                }
+            } catch (_: Exception) {
+            }
+        }
+        if (!hasMatch) return null
+        val colorArray = IntArray(text.length) { -1 }
+        val fontPathArray = arrayOfNulls<String>(text.length)
+        for (rule in rules) {
+            try {
+                val regex = regexCache.getOrPut(rule.pattern) { Regex(rule.pattern) }
+                val matches = regex.findAll(text)
+                for (match in matches) {
+                    for (i in match.range) {
+                        colorArray[i] = rule.color
+                        if (rule.fontPath.isNotEmpty()) {
+                            fontPathArray[i] = rule.fontPath
+                        }
+                    }
+                }
+            } catch (_: Exception) {
+            }
+        }
+        return RegexMatchResult(colorArray, fontPathArray)
+    }
+
+    private data class WordStyle(
+        val color: Int?,
+        val fontPath: String?
+    )
+
+    private fun buildWordStyles(
+        words: List<String>,
+        lineText: String,
+        colorArray: IntArray,
+        fontPathArray: Array<String?>,
+        lineStart: Int
+    ): List<WordStyle> {
+        val wordStyles = mutableListOf<WordStyle>()
+        var charOffset = 0
+        for (word in words) {
+            val wordLen = word.length
+            var color: Int? = null
+            var fontPath: String? = null
+            for (j in 0 until wordLen) {
+                val idx = lineStart + charOffset + j
+                if (color == null && colorArray[idx] != -1) {
+                    color = colorArray[idx]
+                }
+                if (fontPath == null && fontPathArray[idx] != null) {
+                    fontPath = fontPathArray[idx]
+                }
+                if (color != null && fontPath != null) break
+            }
+            wordStyles.add(WordStyle(color, fontPath))
+            charOffset += wordLen
+        }
+        return wordStyles
+    }
+
 }

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

@@ -342,6 +342,7 @@ class BookSourceEditActivity :
         contentEntities.clear()
         contentEntities.apply {
             add(EditEntity("content", cr.content, R.string.rule_book_content))
+            add(EditEntity("subContent", cr.subContent, R.string.rule_sub_content))
             add(EditEntity("title", cr.title, R.string.rule_chapter_name))
             add(EditEntity("nextContentUrl", cr.nextContentUrl, R.string.rule_next_content))
             add(EditEntity("webJs", cr.webJs, R.string.rule_web_js))
@@ -530,6 +531,7 @@ class BookSourceEditActivity :
             it.value = it.value?.takeIf { s -> s.isNotBlank() }
             when (it.key) {
                 "content" -> contentRule.content = viewModel.ruleComplete(it.value)
+                "subContent" -> contentRule.subContent = viewModel.ruleComplete(it.value)
                 "title" -> contentRule.title = viewModel.ruleComplete(it.value)
                 "nextContentUrl" -> contentRule.nextContentUrl =
                     viewModel.ruleComplete(it.value, type = 2)

+ 4 - 0
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -7,10 +7,12 @@ import androidx.lifecycle.DefaultLifecycleObserver
 import androidx.lifecycle.LifecycleOwner
 import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.getPrefBoolean
+import io.legado.app.utils.getPrefFloat
 import io.legado.app.utils.getPrefInt
 import io.legado.app.utils.getPrefLong
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.putPrefBoolean
+import io.legado.app.utils.putPrefFloat
 import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.putPrefLong
 import io.legado.app.utils.putPrefString
@@ -63,6 +65,7 @@ fun <T> prefDelegate(
                 defaultValue is Int -> appCtx.getPrefInt(key, defaultValue) as T
                 defaultValue is Boolean -> appCtx.getPrefBoolean(key, defaultValue) as T
                 defaultValue is Long -> appCtx.getPrefLong(key, defaultValue) as T
+                defaultValue is Float -> appCtx.getPrefFloat(key, defaultValue) as T
                 else -> defaultValue
             }
         }
@@ -78,6 +81,7 @@ fun <T> prefDelegate(
                     is Int -> appCtx.putPrefInt(key, value)
                     is Boolean -> appCtx.putPrefBoolean(key, value)
                     is Long -> appCtx.putPrefLong(key, value)
+                    is Float -> appCtx.putPrefFloat(key, value)
                 }
                 _value.value = value
                 onValueChange?.invoke(value)

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

@@ -53,6 +53,16 @@ object BookshelfConfig {
      */
     var showLastUpdateTime by prefDelegate(PreferKey.showLastUpdateTime, false)
 
+    /**
+     * 是否在列表中显示书籍简介
+     */
+    var showBookIntro by prefDelegate(PreferKey.showBookIntro, false)
+
+    /**
+     * 列表模式下简介显示行数 (0为显示全部)
+     */
+    var bookshelfIntroMaxLines by prefDelegate(PreferKey.bookshelfIntroMaxLines, 0)
+
     /**
      * 是否显示等待更新的书籍数量
      */

+ 3 - 1
app/src/main/java/io/legado/app/ui/config/coverConfig/CoverManageSheet.kt

@@ -30,6 +30,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
@@ -86,7 +87,8 @@ fun CoverManageSheet(
                 items(coverList) { path ->
                     Box {
                         NormalCard(
-                            cornerRadius = 12.dp
+                            cornerRadius = 12.dp,
+                            containerColor = Color.Transparent
                         ) {
                             AsyncImage(
                                 model = path,

+ 391 - 0
app/src/main/java/io/legado/app/ui/config/customTheme/CustomThemeScreen.kt

@@ -0,0 +1,391 @@
+package io.legado.app.ui.config.customTheme
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.ui.draw.clip
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringArrayResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.google.android.material.color.DynamicColors
+import com.google.android.material.color.DynamicColorsOptions
+import io.legado.app.R
+import io.legado.app.lib.theme.ThemeStore
+import io.legado.app.lib.theme.primaryColor
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+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.settingItem.DropdownListSettingItem
+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.SwitchSettingItem
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomThemeScreen(
+    onBackClick: () -> Unit,
+    onNavigateToThemePack: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    var showColorPicker by remember { mutableStateOf(false) }
+    var currentColorKey by remember { mutableStateOf("themeColor") }
+    val context = LocalContext.current
+
+    val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization
+
+    val themeColor = ThemeConfig.themeColor
+    val secondaryThemeColor = ThemeConfig.secondaryThemeColor
+    val primaryTextColor = ThemeConfig.primaryTextColor
+    val secondaryTextColor = ThemeConfig.secondaryTextColor
+    val themeBackgroundColor = ThemeConfig.themeBackgroundColor
+    val labelContainerColor = ThemeConfig.labelContainerColor
+
+    val primaryColor = MaterialTheme.colorScheme.primary
+
+    // 自定义主题 seed color
+    var showSeedColorPicker by remember { mutableStateOf(false) }
+    var pickNightSeedColor by remember { mutableStateOf(false) }
+    val primaryColorValue = remember { mutableIntStateOf(ThemeConfig.cPrimary) }
+    val nightPrimaryColorValue = remember { mutableIntStateOf(ThemeConfig.cNPrimary) }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.custom_theme),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            // Master switch: ON = deep color overrides, OFF = seed color mode
+            item {
+                SplicedColumnGroup {
+                    SwitchSettingItem(
+                        title = "使用色板生成颜色",
+                        checked = !enableDeepPersonalization,
+                        onCheckedChange = { ThemeConfig.enableDeepPersonalization = !it }
+                    )
+                }
+            }
+
+            // Color settings vs Seed color toggle based on enableDeepPersonalization
+            if (enableDeepPersonalization) {
+                item {
+                    SplicedColumnGroup(title = stringResource(R.string.color_setting)) {
+                    // Primary colors
+                    ClickableSettingItem(
+                        title = "主题色",
+                        option = if (themeColor != 0) "#${Integer.toHexString(themeColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "themeColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (themeColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(themeColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = "次要主题色",
+                        option = if (secondaryThemeColor != 0) "#${Integer.toHexString(secondaryThemeColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "secondaryThemeColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (secondaryThemeColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(secondaryThemeColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = "主要字体色",
+                        option = if (primaryTextColor != 0) "#${Integer.toHexString(primaryTextColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "primaryTextColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (primaryTextColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(primaryTextColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = "次要字体色",
+                        option = if (secondaryTextColor != 0) "#${Integer.toHexString(secondaryTextColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "secondaryTextColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (secondaryTextColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(secondaryTextColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = "背景色",
+                        option = if (themeBackgroundColor != 0) "#${Integer.toHexString(themeBackgroundColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "themeBackgroundColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (themeBackgroundColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(themeBackgroundColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+
+                    ClickableSettingItem(
+                        title = "标签容器色",
+                        option = if (labelContainerColor != 0) "#${Integer.toHexString(labelContainerColor).uppercase()}" else stringResource(R.string.click_to_select),
+                        onClick = {
+                            currentColorKey = "labelContainerColor"
+                            showColorPicker = true
+                        },
+                        trailingContent = {
+                            if (labelContainerColor != 0) {
+                                Box(
+                                    modifier = Modifier
+                                        .size(28.dp)
+                                        .clip(CircleShape)
+                                        .background(Color(labelContainerColor))
+                                        .border(
+                                            1.dp,
+                                            MaterialTheme.colorScheme.outlineVariant,
+                                            CircleShape
+                                        )
+                                )
+                            }
+                        }
+                    )
+                }
+            }
+            } else {
+                item {
+                    SplicedColumnGroup(title = stringResource(R.string.custom_theme)) {
+                        ClickableSettingItem(
+                            title = stringResource(R.string.seed_color),
+                            description = stringResource(R.string.day),
+                            option = formatColorOption(primaryColorValue.intValue)
+                                ?: stringResource(R.string.click_to_select),
+                            onClick = {
+                                pickNightSeedColor = false
+                                showSeedColorPicker = true
+                            },
+                            trailingContent = { ColorSwatch(colorValue = primaryColorValue.intValue) }
+                        )
+                        ClickableSettingItem(
+                            title = stringResource(R.string.seed_color),
+                            description = stringResource(R.string.night),
+                            option = formatColorOption(nightPrimaryColorValue.intValue)
+                                ?: stringResource(R.string.click_to_select),
+                            onClick = {
+                                pickNightSeedColor = true
+                                showSeedColorPicker = true
+                            },
+                            trailingContent = { ColorSwatch(colorValue = nightPrimaryColorValue.intValue) }
+                        )
+                        DropdownListSettingItem(
+                            title = stringResource(R.string.palette_style),
+                            selectedValue = ThemeConfig.paletteStyle,
+                            displayEntries = stringArrayResource(R.array.paletteStyle),
+                            entryValues = stringArrayResource(R.array.paletteStyle_value),
+                            onValueChange = { ThemeConfig.paletteStyle = it }
+                        )
+                        DropdownListSettingItem(
+                            title = stringResource(R.string.preferred_contrast),
+                            selectedValue = ThemeConfig.customContrast,
+                            displayEntries = stringArrayResource(R.array.customContrast),
+                            entryValues = stringArrayResource(R.array.customContrast_value),
+                            onValueChange = { ThemeConfig.customContrast = it }
+                        )
+                        DropdownListSettingItem(
+                            title = stringResource(R.string.material_version),
+                            selectedValue = ThemeConfig.materialVersion,
+                            displayEntries = stringArrayResource(R.array.materialVersion),
+                            entryValues = stringArrayResource(R.array.materialVersion_value),
+                            onValueChange = { ThemeConfig.materialVersion = it }
+                        )
+                    }
+                }
+            }
+
+            // Theme Pack entry
+            item {
+                SplicedColumnGroup {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.theme_pack),
+                        description = stringResource(R.string.theme_pack_s),
+                        onClick = onNavigateToThemePack
+                    )
+                }
+            }
+
+        }
+
+        // Deep personalization color picker
+        ColorPickerSheet(
+            show = showColorPicker,
+            initialColor = when (currentColorKey) {
+                "themeColor" -> themeColor
+                "secondaryThemeColor" -> secondaryThemeColor
+                "primaryTextColor" -> primaryTextColor
+                "secondaryTextColor" -> secondaryTextColor
+                "themeBackgroundColor" -> themeBackgroundColor
+                "labelContainerColor" -> labelContainerColor
+                else -> 0
+            },
+            onDismissRequest = { showColorPicker = false },
+            onColorSelected = {
+                when (currentColorKey) {
+                    "themeColor" -> ThemeConfig.themeColor = it
+                    "secondaryThemeColor" -> ThemeConfig.secondaryThemeColor = it
+                    "primaryTextColor" -> ThemeConfig.primaryTextColor = it
+                    "secondaryTextColor" -> ThemeConfig.secondaryTextColor = it
+                    "themeBackgroundColor" -> ThemeConfig.themeBackgroundColor = it
+                    "labelContainerColor" -> ThemeConfig.labelContainerColor = it
+                }
+            }
+        )
+
+        // Seed color picker (from ThemeConfigScreen)
+        ColorPickerSheet(
+            show = showSeedColorPicker,
+            initialColor = if (pickNightSeedColor) {
+                nightPrimaryColorValue.value
+            } else {
+                primaryColorValue.value
+            },
+            onDismissRequest = { showSeedColorPicker = false },
+            onColorSelected = { color ->
+                if (pickNightSeedColor) {
+                    nightPrimaryColorValue.intValue = color
+                    ThemeConfig.cNPrimary = color
+                } else {
+                    primaryColorValue.intValue = color
+                    ThemeConfig.cPrimary = color
+                    ThemeStore.editTheme(context)
+                        .primaryColor(color)
+                        .apply()
+                    DynamicColors.applyToActivitiesIfAvailable(
+                        context.applicationContext as android.app.Application,
+                        DynamicColorsOptions.Builder()
+                            .setContentBasedSource(context.primaryColor)
+                            .build()
+                    )
+                }
+            }
+        )
+
+    }
+}
+
+private fun formatColorOption(colorValue: Int): String? {
+    if (colorValue == 0) return null
+    return "#${Integer.toHexString(colorValue).uppercase()}"
+}
+
+@Composable
+private fun ColorSwatch(colorValue: Int) {
+    if (colorValue == 0) return
+    Box(
+        modifier = Modifier
+            .size(28.dp)
+            .clip(CircleShape)
+            .background(Color(colorValue))
+            .border(
+                1.dp,
+                MaterialTheme.colorScheme.outlineVariant,
+                CircleShape
+            )
+    )
+}

+ 4 - 0
app/src/main/java/io/legado/app/ui/config/mainConfig/MainConfig.kt

@@ -21,4 +21,8 @@ object MainConfig {
     var autoRefreshBook by prefDelegate(PreferKey.autoRefresh, false)
     var autoCheckNewBackup by prefDelegate(PreferKey.autoCheckNewBackup, true)
     var showStatusBar by prefDelegate(PreferKey.showStatusBar, true)
+    var navIconBookshelf by prefDelegate(PreferKey.navIconBookshelf, "")
+    var navIconExplore by prefDelegate(PreferKey.navIconExplore, "")
+    var navIconRss by prefDelegate(PreferKey.navIconRss, "")
+    var navIconMy by prefDelegate(PreferKey.navIconMy, "")
 }

+ 185 - 0
app/src/main/java/io/legado/app/ui/config/personalizationConfig/FontSelectScreen.kt

@@ -0,0 +1,185 @@
+package io.legado.app.ui.config.personalizationConfig
+
+import android.content.Intent
+import android.graphics.Typeface
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.res.ResourcesCompat
+import io.legado.app.R
+import io.legado.app.constant.PreferKey
+import io.legado.app.help.loadFontFiles
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.utils.FileDoc
+import io.legado.app.utils.getPrefString
+import io.legado.app.utils.putPrefString
+import io.legado.app.utils.takePersistablePermissionSafely
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FontSelectScreen(
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    var fontItems by remember { mutableStateOf<List<FileDoc>>(emptyList()) }
+    var selectedFolderUri by remember { mutableStateOf<Uri?>(null) }
+
+    // 加载字体文件
+    fun loadFonts() {
+        fontItems = loadFontFiles(context, selectedFolderUri)
+    }
+
+    // 从偏好设置中加载保存的字体文件夹路径
+    remember {
+        val savedFontPath = context.getPrefString(PreferKey.fontFolder)
+        if (!savedFontPath.isNullOrEmpty()) {
+            selectedFolderUri = Uri.parse(savedFontPath)
+        }
+        loadFonts()
+    }
+
+    // 文件夹选择启动器
+    val selectFolderLauncher = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenDocumentTree()
+    ) { uri ->
+        if (uri != null) {
+            selectedFolderUri = uri
+            // 获取持久化权限
+            uri.takePersistablePermissionSafely(context, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+            // 保存选择的文件夹路径到偏好设置
+            context.putPrefString(PreferKey.fontFolder, uri.toString())
+            loadFonts()
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.font_setting),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        if (fontItems.isEmpty()) {
+            Box(
+                modifier = Modifier.fillMaxSize(),
+                contentAlignment = Alignment.Center
+            ) {
+                Column(
+                    horizontalAlignment = Alignment.CenterHorizontally,
+                    verticalArrangement = Arrangement.Center
+                ) {
+                    Text(
+                        text = "没有字体文件",
+                        style = MaterialTheme.typography.bodyLarge,
+                        modifier = Modifier.padding(bottom = 16.dp)
+                    )
+                    Button(onClick = { selectFolderLauncher.launch(null) }) {
+                        Text(text = stringResource(R.string.select_font))
+                    }
+                }
+            }
+        } else {
+            LazyVerticalGrid(
+                columns = GridCells.Fixed(2),
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = adaptiveContentPadding(
+                    top = paddingValues.calculateTopPadding(),
+                    bottom = 120.dp
+                ),
+                verticalArrangement = Arrangement.spacedBy(16.dp),
+                horizontalArrangement = Arrangement.spacedBy(16.dp)
+            ) {
+                items(fontItems) {
+                    FontItem(it) {
+                        // 选择字体的逻辑
+                        ThemeConfig.appFontPath = it.uri.toString()
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun FontItem(fontDoc: FileDoc, onFontSelected: (FileDoc) -> Unit) {
+    val context = LocalContext.current
+    
+    Card(
+        modifier = Modifier.fillMaxWidth().height(120.dp),
+        onClick = {
+            onFontSelected(fontDoc)
+        },
+        colors = CardDefaults.cardColors(
+            containerColor = MaterialTheme.colorScheme.surfaceVariant
+        )
+    ) {
+        // 使用AndroidView显示使用对应字体的文本
+        AndroidView(
+            factory = { ctx ->
+                android.widget.TextView(ctx).apply {
+                    text = fontDoc.name
+                    textSize = 16f
+                    // 设置垂直和水平居中
+                    gravity = android.view.Gravity.CENTER
+                    // 设置最大行数为2,超过省略
+                    maxLines = 2
+                    ellipsize = android.text.TextUtils.TruncateAt.END
+                    // 加载并设置字体
+                    runCatching {
+                        val typeface: Typeface? = if (fontDoc.uri.scheme == "content") {
+                            ctx.contentResolver.openFileDescriptor(fontDoc.uri, "r")?.use {
+                                Typeface.Builder(it.fileDescriptor).build()
+                            }
+                        } else {
+                            Typeface.createFromFile(fontDoc.uri.path!!)
+                        }
+                        this.typeface = typeface
+                    }.onFailure {
+                        // 字体加载失败,使用默认字体
+                    }
+                }
+            },
+            modifier = Modifier.fillMaxSize()
+        )
+    }
+}
+

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

@@ -5,6 +5,7 @@ import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
@@ -90,7 +91,10 @@ fun BackgroundImageManageSheet(
                         .aspectRatio(16f / 9f)
                         .padding(16.dp)
                 ) {
-                    Box(contentAlignment = Alignment.Center) {
+                    Box(
+                        modifier = Modifier.fillMaxSize(),
+                        contentAlignment = Alignment.Center
+                    ) {
                         AppIcon(
                             imageVector = Icons.Default.Add,
                             contentDescription = "Add",

+ 153 - 0
app/src/main/java/io/legado/app/ui/config/themeConfig/LabelColorManageSheet.kt

@@ -0,0 +1,153 @@
+package io.legado.app.ui.config.themeConfig
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.toMutableStateList
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.unit.dp
+import androidx.core.graphics.ColorUtils
+import io.legado.app.help.config.TagColorGenerator
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.MediumOutlinedIconButton
+import io.legado.app.ui.widget.components.button.SmallIconButton
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LabelColorManageSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit
+) {
+    val primaryColor = LegadoTheme.colorScheme.primary
+    val themeColor = ThemeConfig.themeColor
+    val tagColors = remember(show) {
+        ThemeConfig.getCustomTagColors().toMutableStateList()
+    }
+    var showColorPicker by remember { mutableStateOf(false) }
+    var editingIndex by remember { mutableIntStateOf(-1) }
+    var editingTextColor by remember { mutableIntStateOf(0) }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = "管理标签颜色",
+        startAction = {
+            MediumOutlinedIconButton(
+                onClick = {
+                    val baseColor = if (themeColor != 0) Color(themeColor) else primaryColor
+                    val generatedColors = TagColorGenerator.generateTagColors(baseColor)
+                    tagColors.clear()
+                    tagColors.addAll(generatedColors)
+                    ThemeConfig.saveCustomTagColors(tagColors)
+                },
+                imageVector = Icons.Default.AutoAwesome
+            )
+        },
+        endAction = {
+            MediumOutlinedIconButton(
+                onClick = {
+                    tagColors.add(TagColorPair(0, 0))
+                    editingIndex = tagColors.size - 1
+                    editingTextColor = 0
+                    showColorPicker = true
+                },
+                imageVector = Icons.Default.Add
+            )
+        }
+    ) {
+        LazyColumn(
+            modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            items(tagColors.size) { index ->
+                val colorPair = tagColors[index]
+                val label = "标签 ${index + 1}"
+                NormalCard(
+                    modifier = Modifier.fillMaxWidth(),
+                    containerColor = LegadoTheme.colorScheme.onSheetContent
+                ) {
+                    Row(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(12.dp),
+                        horizontalArrangement = Arrangement.SpaceBetween,
+                        verticalAlignment = Alignment.CenterVertically
+                    ) {
+                        TextCard(
+                            text = label,
+                            backgroundColor = if (colorPair.bgColor != 0) Color(colorPair.bgColor) else LegadoTheme.colorScheme.surfaceContainerHighest,
+                            contentColor = if (colorPair.textColor != 0) Color(colorPair.textColor) else LegadoTheme.colorScheme.primary,
+                            cornerRadius = 4.dp,
+                            horizontalPadding = 8.dp,
+                            verticalPadding = 4.dp,
+                            textStyle = LegadoTheme.typography.labelSmall
+                        )
+                        Row(
+                            horizontalArrangement = Arrangement.spacedBy(4.dp)
+                        ) {
+                            SmallIconButton(
+                                onClick = {
+                                    editingIndex = index
+                                    editingTextColor = colorPair.textColor
+                                    showColorPicker = true
+                                },
+                                imageVector = Icons.Default.Edit
+                            )
+                            SmallIconButton(
+                                onClick = {
+                                    tagColors.removeAt(index)
+                                    ThemeConfig.saveCustomTagColors(tagColors)
+                                },
+                                imageVector = Icons.Default.Delete
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    if (showColorPicker && editingIndex in tagColors.indices) {
+        ColorPickerSheet(
+            show = true,
+            initialColor = editingTextColor,
+            onDismissRequest = { showColorPicker = false },
+            onColorSelected = { selectedColor ->
+                val hsl = FloatArray(3)
+                ColorUtils.colorToHSL(selectedColor, hsl)
+                hsl[1] = (hsl[1] * 0.4f).coerceAtMost(0.35f)
+                hsl[2] = 0.90f
+                val bgColor = Color.hsl(hsl[0], hsl[1], hsl[2]).toArgb()
+                tagColors[editingIndex] = TagColorPair(
+                    textColor = selectedColor,
+                    bgColor = bgColor
+                )
+                ThemeConfig.saveCustomTagColors(tagColors)
+                showColorPicker = false
+            }
+        )
+    }
+}

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

@@ -0,0 +1,148 @@
+package io.legado.app.ui.config.themeConfig
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+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.aspectRatio
+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.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.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.legado.app.ui.config.mainConfig.MainConfig
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.button.SmallTonalIconButton
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.text.AppText
+import java.io.File
+
+private data class NavIconDestination(
+    val key: String,
+    val label: String,
+    val path: String,
+    val onSetPath: (String) -> Unit,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun NavIconManageSheet(
+    show: Boolean,
+    onDismissRequest: () -> Unit
+) {
+    val context = LocalContext.current
+    var activeDest by remember { mutableStateOf<String?>(null) }
+
+    val destinations = listOf(
+        NavIconDestination("bookshelf", "书架", MainConfig.navIconBookshelf) { MainConfig.navIconBookshelf = it },
+        NavIconDestination("explore", "发现", MainConfig.navIconExplore) { MainConfig.navIconExplore = it },
+        NavIconDestination("rss", "订阅", MainConfig.navIconRss) { MainConfig.navIconRss = it },
+        NavIconDestination("my", "我的", MainConfig.navIconMy) { MainConfig.navIconMy = it },
+    )
+
+    val selectImage = rememberLauncherForActivityResult(
+        ActivityResultContracts.GetContent()
+    ) { uri ->
+        val dest = activeDest ?: return@rememberLauncherForActivityResult
+        uri?.let {
+            val inputStream = context.contentResolver.openInputStream(it)
+            val iconDir = File(context.filesDir, "nav_icons")
+            iconDir.mkdirs()
+            val destFile = File(iconDir, "$dest.png")
+            inputStream?.use { input ->
+                destFile.outputStream().use { output ->
+                    input.copyTo(output)
+                }
+            }
+            destinations.firstOrNull { it.key == dest }?.onSetPath?.invoke(destFile.absolutePath)
+        }
+        activeDest = null
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = "导航栏图标",
+    ) {
+        Column(modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp)) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 16.dp),
+                horizontalArrangement = Arrangement.spacedBy(12.dp)
+            ) {
+                destinations.forEach { dest ->
+                    Column(
+                        modifier = Modifier.weight(1f),
+                        horizontalAlignment = Alignment.CenterHorizontally
+                    ) {
+                        NormalCard(
+                            onClick = {
+                                activeDest = dest.key
+                                selectImage.launch("image/png")
+                            },
+                            cornerRadius = 12.dp,
+                            containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .aspectRatio(1f)
+                        ) {
+                            if (dest.path.isNotEmpty()) {
+                                Box(modifier = Modifier.fillMaxSize()) {
+                                    AsyncImage(
+                                        model = dest.path,
+                                        contentDescription = dest.label,
+                                        modifier = Modifier.fillMaxSize(),
+                                        contentScale = ContentScale.Fit
+                                    )
+                                    SmallTonalIconButton(
+                                        onClick = { dest.onSetPath("") },
+                                        modifier = Modifier
+                                            .align(Alignment.TopEnd)
+                                            .padding(4.dp)
+                                            .size(24.dp),
+                                        imageVector = Icons.Default.Close
+                                    )
+                                }
+                            } else {
+                                Box(
+                                    modifier = Modifier.fillMaxSize(),
+                                    contentAlignment = Alignment.Center
+                                ) {
+                                    AppIcon(
+                                        imageVector = Icons.Default.Add,
+                                        contentDescription = "添加${dest.label}图标",
+                                        modifier = Modifier.size(32.dp),
+                                        tint = LegadoTheme.colorScheme.primary
+                                    )
+                                }
+                            }
+                        }
+                        AppText(
+                            text = dest.label,
+                            modifier = Modifier.padding(top = 8.dp),
+                        )
+                    }
+                }
+            }
+        }
+    }
+}

+ 82 - 3
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfig.kt

@@ -3,7 +3,16 @@ package io.legado.app.ui.config.themeConfig
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.ui.config.prefDelegate
+import io.legado.app.utils.GSON
+import io.legado.app.utils.getPrefString
 import io.legado.app.utils.postEvent
+import io.legado.app.utils.putPrefString
+import splitties.init.appCtx
+
+data class TagColorPair(
+    val textColor: Int = 0,
+    val bgColor: Int = 0
+)
 
 object ThemeConfig {
 
@@ -17,6 +26,16 @@ object ThemeConfig {
 
     var enableProgressiveBlur by prefDelegate(PreferKey.enableProgressiveBlur, false)
 
+    var topBarBlurRadius by prefDelegate(PreferKey.topBarBlurRadius, 24)
+
+    var bottomBarBlurRadius by prefDelegate(PreferKey.bottomBarBlurRadius, 8)
+
+    var topBarBlurAlpha by prefDelegate(PreferKey.topBarBlurAlpha, 73)
+
+    var bottomBarBlurAlpha by prefDelegate(PreferKey.bottomBarBlurAlpha, 40)
+
+    var bottomBarLensRadius by prefDelegate(PreferKey.bottomBarLensRadius, 24f)
+
     var useFlexibleTopAppBar by prefDelegate(PreferKey.useFlexibleTopAppBar, true)
 
     var paletteStyle by prefDelegate(PreferKey.paletteStyle, "tonalSpot")
@@ -56,9 +75,47 @@ object ThemeConfig {
         postEvent(EventBus.RECREATE, "")
     }
 
-    var cPrimary by prefDelegate(PreferKey.cPrimary, 0) {
-        postEvent(EventBus.RECREATE, "")
-    }
+    var appFontPath: String?
+        get() = appCtx.getPrefString(PreferKey.appFontPath)
+        set(value) {
+            appCtx.putPrefString(PreferKey.appFontPath, value)
+            postEvent(EventBus.RECREATE, "")
+        }
+
+    var cPrimary by prefDelegate(PreferKey.cPrimary, 0)
+
+    var enableDeepPersonalization by prefDelegate(PreferKey.enableDeepPersonalization, false)
+
+    var themeColor by prefDelegate(PreferKey.themeColor, 0)
+
+    var secondaryThemeColor by prefDelegate(PreferKey.secondaryThemeColor, 0)
+
+    var primaryTextColor by prefDelegate(PreferKey.primaryTextColor, 0)
+
+    var secondaryTextColor by prefDelegate(PreferKey.secondaryTextColor, 0)
+
+    var themeBackgroundColor by prefDelegate(PreferKey.themeBackgroundColor, 0)
+
+    var labelContainerColor by prefDelegate(PreferKey.labelContainerColor, 0)
+
+    var enableContainerBorder by prefDelegate(PreferKey.enableContainerBorder, false)
+
+    var containerBorderWidth by prefDelegate(PreferKey.containerBorderWidth, 1f)
+
+    var containerBorderStyle by prefDelegate(PreferKey.containerBorderStyle, "solid")
+
+    var containerBorderColor by prefDelegate(PreferKey.containerBorderColor, 0)
+
+    var containerBorderDashWidth by prefDelegate(PreferKey.containerBorderDashWidth, 4f)
+
+    var enableItemDivider by prefDelegate(PreferKey.enableItemDivider, false)
+
+    var itemDividerWidth by prefDelegate(PreferKey.itemDividerWidth, 1f)
+
+    var itemDividerLength by prefDelegate(PreferKey.itemDividerLength, 80f)
+    var itemDividerColor by prefDelegate(PreferKey.itemDividerColor, 0)
+
+    var bookInfoInputColor by prefDelegate(PreferKey.bookInfoInputColor, 0)
 
     var cNPrimary by prefDelegate(PreferKey.cNPrimary, 0) {
         postEvent(EventBus.RECREATE, "")
@@ -70,6 +127,28 @@ object ThemeConfig {
 
     var launcherIcon by prefDelegate(PreferKey.launcherIcon, "ic_launcher")
 
+    var enableCustomTagColors by prefDelegate(PreferKey.enableCustomTagColors, false)
+
+    var customTagColorsJson: String?
+        get() = appCtx.getPrefString(PreferKey.customTagColors)
+        set(value) {
+            appCtx.putPrefString(PreferKey.customTagColors, value)
+        }
+
+    fun getCustomTagColors(): List<TagColorPair> {
+        return try {
+            val json = customTagColorsJson
+            if (json.isNullOrBlank()) emptyList()
+            else GSON.fromJson(json, Array<TagColorPair>::class.java).toList()
+        } catch (e: Exception) {
+            emptyList()
+        }
+    }
+
+    fun saveCustomTagColors(colors: List<TagColorPair>) {
+        customTagColorsJson = GSON.toJson(colors)
+    }
+
     var showDiscovery by prefDelegate(PreferKey.showDiscovery, true)
 
     var showRss by prefDelegate(PreferKey.showRss, true)

File diff suppressed because it is too large
+ 511 - 367
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeConfigScreen.kt


+ 0 - 161
app/src/main/java/io/legado/app/ui/config/themeConfig/ThemeListDialog.kt

@@ -1,161 +0,0 @@
-package io.legado.app.ui.config.themeConfig
-
-import androidx.compose.foundation.background
-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.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.FileDownload
-import androidx.compose.material.icons.filled.Share
-import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.core.graphics.toColorInt
-import io.legado.app.R
-import io.legado.app.help.config.OldThemeConfig
-import io.legado.app.lib.theme.primaryColor
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.alert.AppAlertDialog
-import io.legado.app.ui.widget.components.button.MediumIconButton
-import io.legado.app.ui.widget.components.card.GlassCard
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
-import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.utils.GSON
-import io.legado.app.utils.getClipText
-import io.legado.app.utils.share
-import io.legado.app.utils.toastOnUi
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ThemeListDialog(
-    show: Boolean,
-    onDismissRequest: () -> Unit
-) {
-    val context = LocalContext.current
-    var listVersion by remember { mutableIntStateOf(0) }
-    var deleteIndex by remember { mutableStateOf<Int?>(null) }
-    val themeList = remember(listVersion) { OldThemeConfig.configList.toList() }
-
-    AppModalBottomSheet(
-        show = show,
-        onDismissRequest = onDismissRequest,
-        title = stringResource(R.string.theme_list),
-        endAction = {
-            MediumIconButton(
-                onClick = {
-                    val clipText = context.getClipText()
-                    if (clipText != null && OldThemeConfig.addConfig(clipText)) {
-                        listVersion++
-                    } else {
-                        context.toastOnUi("Import failed")
-                    }
-                },
-                imageVector = Icons.Default.FileDownload
-            )
-        }
-    ) {
-        Column(
-            modifier = Modifier
-                .fillMaxWidth()
-                .padding(bottom = 24.dp)
-        ) {
-            LazyColumn(
-                modifier = Modifier.fillMaxWidth(),
-                verticalArrangement = Arrangement.spacedBy(12.dp)
-            ) {
-                itemsIndexed(themeList, key = { _, item -> item.themeName }) { index, item ->
-                    GlassCard(
-                        onClick = {
-                            OldThemeConfig.applyConfig(context, item)
-                        },
-                        modifier = Modifier
-                            .fillMaxWidth(),
-                        cornerRadius = 20.dp,
-                        containerColor = if (item.primaryColor.toColorInt() == context.primaryColor) {
-                            MaterialTheme.colorScheme.secondaryContainer
-                        } else {
-                            MaterialTheme.colorScheme.surfaceContainer
-                        }
-                    ) {
-                        Row(
-                            modifier = Modifier
-                                .fillMaxWidth()
-                                .padding(start = 16.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
-                            verticalAlignment = Alignment.CenterVertically
-                        ) {
-                            Box(
-                                modifier = Modifier
-                                    .padding(end = 12.dp)
-                                    .background(
-                                        color = Color(
-                                            item.primaryColor.toColorInt()
-                                        ),
-                                        shape = MaterialTheme.shapes.large
-                                    )
-                                    .padding(8.dp)
-                            )
-                            AppText(
-                                text = item.themeName,
-                                style = LegadoTheme.typography.bodyLarge,
-                                modifier = Modifier.weight(1f)
-                            )
-                            IconButton(
-                                onClick = {
-                                    val json = GSON.toJson(item)
-                                    context.share(json, "主题分享")
-                                }
-                            ) {
-                                Icon(
-                                    imageVector = Icons.Default.Share,
-                                    contentDescription = stringResource(R.string.share)
-                                )
-                            }
-                            IconButton(onClick = { deleteIndex = index }) {
-                                Icon(
-                                    imageVector = Icons.Default.Delete,
-                                    contentDescription = stringResource(R.string.delete)
-                                )
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    AppAlertDialog(
-        data = deleteIndex,
-        onDismissRequest = { deleteIndex = null },
-        title = stringResource(R.string.delete),
-        confirmText = stringResource(android.R.string.ok),
-        onConfirm = { index ->
-            OldThemeConfig.delConfig(index)
-            listVersion++
-            deleteIndex = null
-        },
-        dismissText = stringResource(android.R.string.cancel),
-        onDismiss = { deleteIndex = null }
-    )
-}

+ 372 - 0
app/src/main/java/io/legado/app/ui/config/themePack/ThemePackScreen.kt

@@ -0,0 +1,372 @@
+package io.legado.app.ui.config.themePack
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.help.config.ThemePackConfig
+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.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.text.AppText
+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 io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ThemePackScreen(
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    var listVersion by remember { mutableIntStateOf(0) }
+    var showAddDialog by remember { mutableStateOf(false) }
+    var newThemeName by remember { mutableStateOf("") }
+    var deleteTarget by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
+    var applyTarget by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
+    var exportingPack by remember { mutableStateOf<ThemePackConfig.ThemePack?>(null) }
+
+    val packList = remember(listVersion) { ThemePackConfig.packList.toList() }
+
+    val exportLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.CreateDocument("application/zip")
+    ) { uri ->
+        uri?.let {
+            val pack = exportingPack ?: return@let
+            exportingPack = null
+            scope.launch(Dispatchers.IO) {
+                kotlin.runCatching {
+                    val tempFile = File(context.cacheDir, "theme_export_${pack.name}.zip")
+                    ThemePackConfig.exportToZip(pack, tempFile)
+                    context.contentResolver.openOutputStream(uri)?.use { os ->
+                        tempFile.inputStream().use { it.copyTo(os) }
+                    }
+                    tempFile.delete()
+                    withContext(Dispatchers.Main) {
+                        context.toastOnUi("导出成功")
+                    }
+                }.onFailure {
+                    withContext(Dispatchers.Main) {
+                        context.toastOnUi("导出失败: ${it.message}")
+                    }
+                }
+            }
+        }
+    }
+
+    val importLauncher = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        uri?.let {
+            scope.launch(Dispatchers.IO) {
+                kotlin.runCatching {
+                    val tempFile = File(context.cacheDir, "theme_import_${System.currentTimeMillis()}.zip")
+                    context.contentResolver.openInputStream(it)?.use { input ->
+                        tempFile.outputStream().use { output ->
+                            input.copyTo(output)
+                        }
+                    }
+                    val pack = ThemePackConfig.importFromZip(tempFile)
+                    tempFile.delete()
+                    if (pack != null) {
+                        listVersion++
+                        withContext(Dispatchers.Main) {
+                            context.toastOnUi("导入成功: ${pack.name}")
+                        }
+                    } else {
+                        withContext(Dispatchers.Main) {
+                            context.toastOnUi("导入失败: 无效的主题包")
+                        }
+                    }
+                }.onFailure {
+                    withContext(Dispatchers.Main) {
+                        context.toastOnUi("导入失败: ${it.message}")
+                    }
+                }
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.theme_pack),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup {
+                    ClickableSettingItem(
+                        title = "新增主题",
+                        description = "保存当前设置为新主题",
+                        onClick = {
+                            newThemeName = ""
+                            showAddDialog = true
+                        }
+                    )
+                    ClickableSettingItem(
+                        title = "导入主题",
+                        description = "从zip包导入主题",
+                        onClick = {
+                            importLauncher.launch(arrayOf("application/zip", "application/octet-stream"))
+                        }
+                    )
+                }
+            }
+
+            if (packList.isNotEmpty()) {
+                item {
+                    AppText(
+                        text = "主题列表",
+                        style = MaterialTheme.typography.titleSmall,
+                        color = MaterialTheme.colorScheme.onSurfaceVariant,
+                        modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 8.dp)
+                    )
+                }
+
+                items(packList, key = { it.folderName }) { pack ->
+                    ThemePackItem(
+                        pack = pack,
+                        onApply = { applyTarget = pack },
+                        onExport = { exportingPack = pack },
+                        onDelete = { deleteTarget = pack }
+                    )
+                }
+            }
+        }
+    }
+
+    AppAlertDialog(
+        data = if (showAddDialog) "add" else null,
+        onDismissRequest = { showAddDialog = false },
+        title = "新增主题",
+        confirmText = "保存",
+        onConfirm = {
+            if (newThemeName.isNotBlank()) {
+                scope.launch(Dispatchers.IO) {
+                    ThemePackConfig.createFromCurrent(newThemeName)
+                    listVersion++
+                }
+                showAddDialog = false
+            }
+        },
+        dismissText = "取消",
+        onDismiss = { showAddDialog = false },
+        content = {
+            io.legado.app.ui.widget.components.AppTextField(
+                value = newThemeName,
+                onValueChange = { newThemeName = it },
+                placeholder = { AppText(text = "请输入主题名称") },
+                singleLine = true,
+                modifier = Modifier.fillMaxWidth()
+            )
+        }
+    )
+
+    AppAlertDialog(
+        data = applyTarget,
+        onDismissRequest = { applyTarget = null },
+        title = "应用主题",
+        confirmText = "应用",
+        onConfirm = { pack ->
+            scope.launch(Dispatchers.IO) {
+                ThemePackConfig.applyPack(pack)
+                withContext(Dispatchers.Main) {
+                    context.toastOnUi("主题已应用,请重启生效")
+                }
+            }
+            applyTarget = null
+        },
+        dismissText = "取消",
+        onDismiss = { applyTarget = null },
+        text = "确定应用主题「${applyTarget?.name}」?应用后需要重启才能完全生效。"
+    )
+
+    AppAlertDialog(
+        data = deleteTarget,
+        onDismissRequest = { deleteTarget = null },
+        title = "删除主题",
+        confirmText = "删除",
+        onConfirm = { pack ->
+            scope.launch(Dispatchers.IO) {
+                ThemePackConfig.deletePack(pack)
+                listVersion++
+            }
+            deleteTarget = null
+        },
+        dismissText = "取消",
+        onDismiss = { deleteTarget = null },
+        text = "确定删除主题「${deleteTarget?.name}」?此操作不可恢复。"
+    )
+
+    LaunchedEffect(exportingPack) {
+        exportingPack?.let { pack ->
+            kotlin.runCatching {
+                exportLauncher.launch("${pack.name}.zip")
+            }.onFailure {
+                exportingPack = null
+                context.toastOnUi("导出失败: ${it.message}")
+            }
+        }
+    }
+}
+
+@Composable
+private fun ThemePackItem(
+    pack: ThemePackConfig.ThemePack,
+    onApply: () -> Unit,
+    onExport: () -> Unit,
+    onDelete: () -> Unit
+) {
+    GlassCard(
+        onClick = onApply,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 16.dp, vertical = 4.dp),
+        cornerRadius = 20.dp
+    ) {
+        Column(
+            modifier = Modifier.fillMaxWidth()
+        ) {
+            val packDir = remember { ThemePackConfig.getPackDir(pack) }
+            val bgFile = remember(pack.folderName) {
+                packDir.listFiles()?.firstOrNull {
+                    it.isFile && (it.name.startsWith("bg_light") || it.name.startsWith("bg_dark"))
+                }
+            }
+            val coverFile = remember(pack.folderName) {
+                packDir.listFiles()?.firstOrNull {
+                    it.isFile && it.name.startsWith("cover_day_")
+                }
+            }
+            val previewFile = bgFile ?: coverFile
+
+            Box(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .height(120.dp)
+                    .clip(RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp))
+                    .background(
+                        if (pack.themeColor != 0) Color(pack.themeColor)
+                        else MaterialTheme.colorScheme.primary
+                    )
+            ) {
+                if (previewFile != null) {
+                    AsyncImage(
+                        model = previewFile,
+                        contentDescription = null,
+                        contentScale = ContentScale.Crop,
+                        modifier = Modifier.fillMaxSize()
+                    )
+                }
+            }
+
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                Column(
+                    modifier = Modifier.weight(1f)
+                ) {
+                    AppText(
+                        text = pack.name,
+                        style = MaterialTheme.typography.bodyLarge
+                    )
+                    val hasCustomIcon = pack.navIconBookshelf.isNotEmpty() ||
+                            pack.navIconExplore.isNotEmpty() ||
+                            pack.navIconRss.isNotEmpty() ||
+                            pack.navIconMy.isNotEmpty()
+                    val hasCover = pack.defaultCover.isNotEmpty() || pack.defaultCoverDark.isNotEmpty()
+                    val features = mutableListOf<String>()
+                    if (pack.appFontPath != null) features.add("应用字体")
+                    if (pack.bgImageLight != null || pack.bgImageDark != null) features.add("应用背景")
+                    if (hasCover) features.add("封面图片")
+                    if (hasCustomIcon) features.add("图标")
+                    if (features.isNotEmpty()) {
+                        AppText(
+                            text = features.joinToString(" · "),
+                            style = MaterialTheme.typography.bodySmall,
+                            color = MaterialTheme.colorScheme.onSurfaceVariant
+                        )
+                    }
+                }
+
+                IconButton(onClick = onExport) {
+                    Icon(
+                        imageVector = Icons.Default.Share,
+                        contentDescription = "导出",
+                        modifier = Modifier.size(20.dp)
+                    )
+                }
+                IconButton(onClick = onDelete) {
+                    Icon(
+                        imageVector = Icons.Default.Delete,
+                        contentDescription = "删除",
+                        modifier = Modifier.size(20.dp),
+                        tint = MaterialTheme.colorScheme.error
+                    )
+                }
+            }
+        }
+    }
+}

+ 68 - 13
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -61,8 +61,11 @@ import io.legado.app.ui.config.backupConfig.BackupConfigScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigScreen
 import io.legado.app.ui.config.mainConfig.MainConfig
 import io.legado.app.ui.config.otherConfig.OtherConfigScreen
+import io.legado.app.ui.config.personalizationConfig.FontSelectScreen
+import io.legado.app.ui.config.customTheme.CustomThemeScreen
 import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
+import io.legado.app.ui.config.themePack.ThemePackScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
 import io.legado.app.ui.rss.read.MainRouteRssRead
@@ -97,6 +100,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
         private const val ROUTE_SETTINGS_COVER = "settings/cover"
         private const val ROUTE_SETTINGS_THEME = "settings/theme"
         private const val ROUTE_SETTINGS_BACKUP = "settings/backup"
+        private const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
         private const val ROUTE_IMPORT_LOCAL = "import/local"
         private const val ROUTE_IMPORT_REMOTE = "import/remote"
         private const val ROUTE_CACHE = "cache"
@@ -278,6 +282,12 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
     @Serializable
     private data object MainRouteSettingsBackup : MainRoute
 
+    @Serializable
+    private data object MainRouteSettingsCustomTheme : MainRoute
+
+    @Serializable
+    private data object MainRouteSettingsThemePack : MainRoute
+
     @Serializable
     private data object MainRouteImportLocal : MainRoute
 
@@ -543,13 +553,27 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                 }
 
                 entry<MainRouteSettingsTheme> {
-                    ThemeConfigScreen(onBackClick = { navigateBack(backStack) })
+                    ThemeConfigScreen(
+                        onBackClick = { navigateBack(backStack) },
+                        onNavigateToCustomTheme = { backStack.add(MainRouteSettingsCustomTheme) }
+                    )
                 }
 
                 entry<MainRouteSettingsBackup> {
                     BackupConfigScreen(onBackClick = { navigateBack(backStack) })
                 }
 
+                entry<MainRouteSettingsCustomTheme> {
+                    CustomThemeScreen(
+                        onBackClick = { navigateBack(backStack) },
+                        onNavigateToThemePack = { backStack.add(MainRouteSettingsThemePack) }
+                    )
+                }
+
+                entry<MainRouteSettingsThemePack> {
+                    ThemePackScreen(onBackClick = { navigateBack(backStack) })
+                }
+
                 entry<MainRouteImportLocal> {
                     ImportBookScreen(
                         onBackClick = { navigateBack(backStack) }
@@ -599,7 +623,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                     }
 
                     DisposableEffect(lifecycleOwner, searchViewModel) {
-                        searchViewModel.onIntent(SearchIntent.ResumeEngine)
                         val observer = LifecycleEventObserver { _, event ->
                             when (event) {
                                 Lifecycle.Event.ON_RESUME -> {
@@ -616,7 +639,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                         lifecycleOwner.lifecycle.addObserver(observer)
                         onDispose {
                             lifecycleOwner.lifecycle.removeObserver(observer)
-                            searchViewModel.onIntent(SearchIntent.PauseEngine)
                         }
                     }
 
@@ -645,15 +667,25 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                         initialSortUrl = route.sortUrl,
                         onBackClick = { navigateBack(backStack) },
                         onOpenRead = { title, origin, link, openUrl ->
-                            navigateToRoute(
-                                backStack,
-                                MainRouteRssRead(
-                                    title = title,
-                                    origin = origin,
-                                    link = link,
-                                    openUrl = openUrl
+                            if (link?.contains("@js:") == true) {
+                                navigateToRoute(
+                                    backStack,
+                                    MainRouteRssSort(
+                                        sourceUrl = origin,
+                                        sortUrl = link
+                                    )
                                 )
-                            )
+                            } else {
+                                navigateToRoute(
+                                    backStack,
+                                    MainRouteRssRead(
+                                        title = title,
+                                        origin = origin,
+                                        link = link,
+                                        openUrl = openUrl
+                                    )
+                                )
+                            }
                         }
                     )
                 }
@@ -668,7 +700,18 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
                     )
                 }
 
-                entry<MainRouteBookInfo> { route ->
+                entry<MainRouteBookInfo>(
+                    metadata = NavDisplay.transitionSpec {
+                        fadeIn(animationSpec = tween(300)) togetherWith
+                            fadeOut(animationSpec = tween(300))
+                    } + NavDisplay.popTransitionSpec {
+                        fadeIn(animationSpec = tween(300)) togetherWith
+                            fadeOut(animationSpec = tween(300))
+                    } + NavDisplay.predictivePopTransitionSpec { _ ->
+                        fadeIn(animationSpec = tween(300)) togetherWith
+                            fadeOut(animationSpec = tween(300))
+                    }
+                ) { route ->
                     val bookInfoViewModel = koinViewModel<BookInfoViewModel>()
                     BookInfoRouteScreen(
                         bookUrl = route.bookUrl,
@@ -734,7 +777,9 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
             MainRouteSettingsRead,
             MainRouteSettingsCover,
             MainRouteSettingsTheme,
-            MainRouteSettingsBackup -> {
+            MainRouteSettingsBackup,
+            MainRouteSettingsCustomTheme,
+            MainRouteSettingsThemePack -> {
                 backStack.clear()
                 backStack.add(MainRouteHome)
                 backStack.add(MainRouteSettings)
@@ -969,6 +1014,16 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
 
     private fun resolveStartRoute(route: String?): MainRoute {
         return when (route) {
+            "main" -> MainRouteHome
+            "settings" -> MainRouteSettings
+            "settings/other" -> MainRouteSettingsOther
+            "settings/read" -> MainRouteSettingsRead
+            "settings/cover" -> MainRouteSettingsCover
+            "settings/theme" -> MainRouteSettingsTheme
+            "settings/backup" -> MainRouteSettingsBackup
+            "settings/custom_theme" -> MainRouteSettingsCustomTheme
+            "import/local" -> MainRouteImportLocal
+            "import/remote" -> MainRouteImportRemote
             ROUTE_MAIN -> MainRouteHome
             ROUTE_SETTINGS -> MainRouteSettings
             ROUTE_SETTINGS_OTHER -> MainRouteSettingsOther

+ 197 - 129
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBars
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.Image
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
@@ -57,12 +58,17 @@ import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.graphics.Color
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.painter.BitmapPainter
 import io.legado.app.R
+import io.legado.app.ui.config.mainConfig.MainConfig
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.main.bookshelf.BookshelfScreen
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
 import io.legado.app.ui.main.explore.ExploreScreen
@@ -149,7 +155,11 @@ fun MainScreen(
     }
 
     val hazeState = remember { HazeState() }
-    val floatingBarSurfaceColor = MaterialTheme.colorScheme.surface
+    val floatingBarSurfaceColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+        Color(ThemeConfig.secondaryThemeColor)
+    } else {
+        MaterialTheme.colorScheme.surface
+    }
     val floatingBarBackdrop = rememberLayerBackdrop {
         drawRect(floatingBarSurfaceColor)
         drawContent()
@@ -270,7 +280,13 @@ fun MainScreen(
                             }
                         },
                         label = if (labelVisibilityMode != "unlabeled") {
-                            { AppText(stringResource(destination.labelId)) }
+                            val hasCustomIcon = when (destination) {
+                                MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
+                                MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
+                                MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
+                                MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
+                            }
+                            if (hasCustomIcon) null else {{ AppText(stringResource(destination.labelId)) }}
                         } else null
                     )
                 }
@@ -280,88 +296,37 @@ fun MainScreen(
         AppScaffold(
             modifier = Modifier.weight(1f),
             bottomBar = {
-                if (!useRail && mainUiState.showBottomView) {
-                    if (useFloatingBottomBar) {
-                        Box(modifier = Modifier.fillMaxWidth()) {
-                            FloatingBottomBar(
-                                modifier = Modifier
-                                    .align(Alignment.BottomCenter)
-                                    .clickable(
-                                        interactionSource = remember { MutableInteractionSource() },
-                                        indication = null,
-                                        onClick = {}
+                if (!useRail && mainUiState.showBottomView && !useFloatingBottomBar) {
+                    AppNavigationBar() {
+                        destinations.forEachIndexed { index, destination ->
+                            val selected = pagerState.targetPage == index
+                            val customIconPath = when (destination) {
+                                MainDestination.Bookshelf -> MainConfig.navIconBookshelf
+                                MainDestination.Explore -> MainConfig.navIconExplore
+                                MainDestination.Rss -> MainConfig.navIconRss
+                                MainDestination.My -> MainConfig.navIconMy
+                            }
+                            AppNavigationBarItem(
+                                selected = selected,
+                                onClick = {
+                                    coroutineScope.launch { pagerState.animateScrollToPage(index) }
+                                },
+                                labelString = stringResource(destination.labelId),
+                                iconVector = AppIcons.mainDestination(destination, selected),
+                                m3Icon = {
+                                    NavigationIcon(
+                                        destination = destination,
+                                        selected = selected
                                     )
-                                    .padding(
-                                        start = 16.dp,
-                                        end = 16.dp,
-                                        bottom = 12.dp + WindowInsets.navigationBars
-                                            .asPaddingValues()
-                                            .calculateBottomPadding()
-                                    ),
-                                selectedIndex = { pagerState.targetPage },
-                                onSelected = { index ->
-                                    coroutineScope.launch {
-                                        pagerState.animateScrollToPage(index)
-                                    }
                                 },
-                                backdrop = floatingBarBackdrop,
-                                tabsCount = destinations.size,
-                                isBlurEnabled = useLiquidGlass
-                            ) {
-                                destinations.forEachIndexed { index, destination ->
-                                    val selected = pagerState.targetPage == index
-                                    FloatingBottomBarItem(
-                                        onClick = {
-                                            coroutineScope.launch {
-                                                pagerState.animateScrollToPage(index)
-                                            }
-                                        },
-                                        modifier = Modifier.defaultMinSize(minWidth = 76.dp)
-                                    ) {
-                                        NavigationIcon(
-                                            destination = destination,
-                                            selected = selected
-                                        )
-                                        if (showLabel && (alwaysShowLabel || selected)) {
-                                            AppText(
-                                                text = stringResource(destination.labelId),
-                                                style = MaterialTheme.typography.labelSmall,
-                                                maxLines = 1,
-                                                overflow = TextOverflow.Ellipsis
-                                            )
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    } else {
-                        AppNavigationBar(
-                            modifier = Modifier
-                                .regularHazeEffect(state = hazeState)
-                        ) {
-                            destinations.forEachIndexed { index, destination ->
-                                val selected = pagerState.targetPage == index
-                                AppNavigationBarItem(
-                                    selected = selected,
-                                    onClick = {
-                                        coroutineScope.launch { pagerState.animateScrollToPage(index) }
-                                    },
-                                    labelString = stringResource(destination.labelId),
-                                    iconVector = AppIcons.mainDestination(destination, selected),
-                                    m3Icon = {
-                                        NavigationIcon(
-                                            destination = destination,
-                                            selected = selected
-                                        )
-                                    },
-                                    m3IndicatorColor = GlassDefaults.glassColor(
-                                        noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
-                                        blurAlpha = GlassDefaults.ThickBlurAlpha
-                                    ),
-                                    m3ShowLabel = showLabel,
-                                    m3AlwaysShowLabel = alwaysShowLabel
-                                )
-                            }
+                                m3IndicatorColor = GlassDefaults.glassColor(
+                                    noBlurColor = MaterialTheme.colorScheme.secondaryContainer,
+                                    blurAlpha = GlassDefaults.ThickBlurAlpha
+                                ),
+                                m3ShowLabel = showLabel && !customIconPath.isNotEmpty(),
+                                m3AlwaysShowLabel = alwaysShowLabel && !customIconPath.isNotEmpty(),
+                                useCustomIcon = customIconPath.isNotEmpty()
+                            )
                         }
                     }
                 }
@@ -369,60 +334,136 @@ fun MainScreen(
             contentWindowInsets = WindowInsets(0)
         ) { _ ->
             Box(
-                modifier = Modifier
-                    .hazeSource(hazeState)
-                    .then(
-                        if (useFloatingBottomBar) {
-                            Modifier.layerBackdrop(floatingBarBackdrop)
+                modifier = Modifier.fillMaxSize()
+            ) {
+                Box(
+                    modifier = Modifier.then(
+                        if (useLiquidGlass) {
+                            Modifier
+                                .hazeSource(hazeState)
+                                .layerBackdrop(floatingBarBackdrop)
                         } else {
                             Modifier
                         }
                     )
-            ) {
-                HorizontalPager(
-                    state = pagerState,
-                    modifier = Modifier.fillMaxSize(),
-                    userScrollEnabled = true,
-                    beyondViewportPageCount = 1
-                ) { page ->
-                    val destination = destinations.getOrNull(page) ?: return@HorizontalPager
-                    when (destination) {
-                        MainDestination.Bookshelf -> BookshelfScreen(
-                            onBookClick = { book ->
-                                context.startActivityForBook(book)
-                            },
-                            onBookLongClick = { book ->
-                                onNavigateToBookInfo(book.name, book.author, book.bookUrl)
-                            },
-                            onNavigateToSearch = { query -> onNavigateToSearch(query) },
-                            onNavigateToRemoteImport = onNavigateToRemoteImport,
-                            onNavigateToLocalImport = onNavigateToLocalImport,
-                            onNavigateToCache = onNavigateToCache,
-                            sharedTransitionScope = sharedTransitionScope,
-                            animatedVisibilityScope = animatedVisibilityScope,
-                        )
+                ) {
+                    HorizontalPager(
+                        state = pagerState,
+                        modifier = Modifier.fillMaxSize(),
+                        userScrollEnabled = true,
+                        beyondViewportPageCount = 1
+                    ) { page ->
+                        val destination = destinations.getOrNull(page) ?: return@HorizontalPager
+                        when (destination) {
+                            MainDestination.Bookshelf -> BookshelfScreen(
+                                onBookClick = { book ->
+                                    context.startActivityForBook(book)
+                                },
+                                onBookLongClick = { book ->
+                                    onNavigateToBookInfo(book.name, book.author, book.bookUrl)
+                                },
+                                onNavigateToSearch = { query -> onNavigateToSearch(query) },
+                                onNavigateToRemoteImport = onNavigateToRemoteImport,
+                                onNavigateToLocalImport = onNavigateToLocalImport,
+                                onNavigateToCache = onNavigateToCache,
+                                sharedTransitionScope = sharedTransitionScope,
+                                animatedVisibilityScope = animatedVisibilityScope,
+                            )
 
-                        MainDestination.Explore -> ExploreScreen(
-                            onOpenExploreShow = onNavigateToExploreShow
-                        )
-                        MainDestination.Rss -> RssScreen(
-                            onOpenSort = { sourceUrl, sortUrl, key ->
-                                onNavigateToRssSort(sourceUrl, sortUrl, key)
+                            MainDestination.Explore -> ExploreScreen(
+                                onOpenExploreShow = onNavigateToExploreShow
+                            )
+                            MainDestination.Rss -> RssScreen(
+                                onOpenSort = { sourceUrl, sortUrl, key ->
+                                    onNavigateToRssSort(sourceUrl, sortUrl, key)
+                                },
+                                onOpenRead = { title, origin, link, openUrl ->
+                                    onNavigateToRssRead(title, origin, link, openUrl)
+                                }
+                            )
+                            MainDestination.My -> MyScreen(
+                                onOpenSettings = onOpenSettings,
+                                onNavigate = { event ->
+                                    if (event == PrefClickEvent.OpenBookCacheManage) {
+                                        onNavigateToBookCacheManage()
+                                    } else {
+                                        viewModel.onPrefClickEvent(event)
+                                    }
+                                }
+                            )
+                        }
+                    }
+                }
+
+                if (!useRail && mainUiState.showBottomView && useFloatingBottomBar) {
+                    Box(modifier = Modifier
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth()
+                    ) {
+                        FloatingBottomBar(
+                            modifier = Modifier
+                                .align(Alignment.BottomCenter)
+                                .clickable(
+                                    interactionSource = remember { MutableInteractionSource() },
+                                    indication = null,
+                                    onClick = {}
+                                )
+                                .padding(
+                                    start = 16.dp,
+                                    end = 16.dp,
+                                    bottom = 12.dp + WindowInsets.navigationBars
+                                        .asPaddingValues()
+                                        .calculateBottomPadding()
+                                ),
+                            selectedIndex = { pagerState.targetPage },
+                            onSelected = { index ->
+                                coroutineScope.launch {
+                                    pagerState.animateScrollToPage(index)
+                                }
                             },
-                            onOpenRead = { title, origin, link, openUrl ->
-                                onNavigateToRssRead(title, origin, link, openUrl)
+                            backdrop = floatingBarBackdrop,
+                            tabsCount = destinations.size,
+                            isBlurEnabled = useLiquidGlass,
+                            hasCustomIcons = destinations.any { dest ->
+                                when (dest) {
+                                    MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
+                                    MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
+                                    MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
+                                    MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
+                                }
                             }
-                        )
-                        MainDestination.My -> MyScreen(
-                            onOpenSettings = onOpenSettings,
-                            onNavigate = { event ->
-                                if (event == PrefClickEvent.OpenBookCacheManage) {
-                                    onNavigateToBookCacheManage()
-                                } else {
-                                    viewModel.onPrefClickEvent(event)
+                        ) {
+                            destinations.forEachIndexed { index, destination ->
+                                val selected = pagerState.targetPage == index
+                                val hasCustomIcon = when (destination) {
+                                    MainDestination.Bookshelf -> MainConfig.navIconBookshelf.isNotEmpty()
+                                    MainDestination.Explore -> MainConfig.navIconExplore.isNotEmpty()
+                                    MainDestination.Rss -> MainConfig.navIconRss.isNotEmpty()
+                                    MainDestination.My -> MainConfig.navIconMy.isNotEmpty()
+                                }
+                                FloatingBottomBarItem(
+                                    onClick = {
+                                        coroutineScope.launch {
+                                            pagerState.animateScrollToPage(index)
+                                        }
+                                    },
+                                    modifier = Modifier.defaultMinSize(minWidth = 76.dp)
+                                ) {
+                                    NavigationIcon(
+                                        destination = destination,
+                                        selected = selected
+                                    )
+                                    if (!hasCustomIcon && showLabel && (alwaysShowLabel || selected)) {
+                                        AppText(
+                                            text = stringResource(destination.labelId),
+                                            style = MaterialTheme.typography.labelSmall,
+                                            maxLines = 1,
+                                            overflow = TextOverflow.Ellipsis
+                                        )
+                                    }
                                 }
                             }
-                        )
+                        }
                     }
                 }
             }
@@ -474,6 +515,33 @@ private fun NavigationIcon(
     selected: Boolean,
     modifier: Modifier = Modifier
 ) {
-    val icon = AppIcons.mainDestination(destination, selected)
-    AppIcon(icon, contentDescription = null, modifier = modifier)
+    val customIconPath = when (destination) {
+        MainDestination.Bookshelf -> MainConfig.navIconBookshelf
+        MainDestination.Explore -> MainConfig.navIconExplore
+        MainDestination.Rss -> MainConfig.navIconRss
+        MainDestination.My -> MainConfig.navIconMy
+    }
+    if (customIconPath.isNotEmpty()) {
+        val context = LocalContext.current
+        val bitmap = remember(customIconPath) {
+            kotlin.runCatching {
+                android.graphics.BitmapFactory.decodeFile(customIconPath)
+            }.getOrNull()
+        }
+        if (bitmap != null) {
+            Image(
+                painter = remember(bitmap) {
+                    BitmapPainter(bitmap.asImageBitmap())
+                },
+                contentDescription = null,
+                modifier = modifier.size(40.dp)
+            )
+        } else {
+            val icon = AppIcons.mainDestination(destination, selected)
+            AppIcon(icon, contentDescription = null, modifier = modifier)
+        }
+    } else {
+        val icon = AppIcons.mainDestination(destination, selected)
+        AppIcon(icon, contentDescription = null, modifier = modifier)
+    }
 }

+ 25 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/BookGroupUi.kt

@@ -0,0 +1,25 @@
+package io.legado.app.ui.main.bookshelf
+
+import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.BookGroup
+
+@Stable
+data class BookGroupUi(
+    val groupId: Long,
+    val groupName: String,
+    val cover: String?,
+    val order: Int,
+    val enableRefresh: Boolean,
+    val show: Boolean,
+    val bookSort: Int
+)
+
+fun BookGroup.toBookGroupUi() = BookGroupUi(
+    groupId = groupId,
+    groupName = groupName,
+    cover = cover,
+    order = order,
+    enableRefresh = enableRefresh,
+    show = show,
+    bookSort = bookSort
+)

+ 144 - 16
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -5,10 +5,12 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.background
+import androidx.compose.foundation.border
 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.Row
 import androidx.compose.foundation.layout.RowScope
 import androidx.compose.foundation.layout.aspectRatio
@@ -24,19 +26,29 @@ 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.draw.drawWithContent
 import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
 import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.drawscope.Stroke
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.constant.BookType
-import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.NormalCard
@@ -44,6 +56,7 @@ import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.cover.CoilBookCover
 import io.legado.app.ui.widget.components.cover.BookshelfCover
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toTimeAgo
 
 /**
@@ -63,24 +76,62 @@ fun BookshelfItem(
     titleEnd: @Composable (() -> Unit)? = null,
     subTitle: String? = null,
     desc: String? = null,
+    descMaxLines: Int = 1,
     extra: @Composable (RowScope.() -> Unit)? = null,
+    bottomContent: @Composable (() -> Unit)? = null,
     titleSmallFont: Boolean = false,
     titleCenter: Boolean = true,
     titleMaxLines: Int = 2,
     coverShadow: Boolean = false,
+    titleColor: Color? = null,
+    descAnnotated: AnnotatedString? = null,
     onClick: () -> Unit,
     onLongClick: (() -> Unit)?
 ) {
-    val selectedColor = if (isSelected) {
-        LegadoTheme.colorScheme.secondaryContainer
+    val containerColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+        Color(ThemeConfig.secondaryThemeColor)
+    } else {
+        LegadoTheme.colorScheme.cardContainer
+    }
+
+    val borderWidth = ThemeConfig.containerBorderWidth.dp
+    val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+        Color(ThemeConfig.containerBorderColor)
+    } else {
+        LegadoTheme.colorScheme.outline
+    }
+    val borderStyle = ThemeConfig.containerBorderStyle
+    val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder
+
+    val borderModifier = if (enableBorder && borderStyle == "solid") {
+        Modifier.border(borderWidth, borderColor, MaterialTheme.shapes.small)
+    } else if (enableBorder) {
+        val dashWidth = ThemeConfig.containerBorderDashWidth
+        val pathEffect = when (borderStyle) {
+            "dashed" -> PathEffect.dashPathEffect(floatArrayOf(dashWidth, dashWidth))
+            "dotted" -> PathEffect.dashPathEffect(floatArrayOf(dashWidth / 2, dashWidth))
+            else -> null
+        }
+        Modifier.drawWithContent {
+            drawContent()
+            val cornerRadius = CornerRadius(8.dp.toPx(), 8.dp.toPx())
+            val halfStroke = borderWidth.toPx() / 2
+            drawRoundRect(
+                color = borderColor,
+                topLeft = Offset(halfStroke, halfStroke),
+                size = Size(size.width - borderWidth.toPx(), size.height - borderWidth.toPx()),
+                cornerRadius = cornerRadius,
+                style = Stroke(width = borderWidth.toPx(), pathEffect = pathEffect)
+            )
+        }
     } else {
         LegadoTheme.colorScheme.surface.copy(alpha = 0f)
+        Modifier
     }
+
     if (isGrid) {
         Box(
             modifier = modifier
-                .clip(MaterialTheme.shapes.small)
-                .background(selectedColor)
                 .combinedClickable(
                     onClick = onClick,
                     onLongClick = onLongClick
@@ -99,7 +150,7 @@ fun BookshelfItem(
                                 MaterialTheme.shapes.extraSmall
                             ) else Modifier
                         )
-                        .clip(MaterialTheme.shapes.extraSmall) // 先阴影后裁剪
+                        .clip(MaterialTheme.shapes.extraSmall)
                 ) {
                     cover(Modifier.fillMaxSize())
                     if (gridStyle == 1) {
@@ -146,14 +197,18 @@ fun BookshelfItem(
             }
         }
     } else {
-        // 列表布局
         Column {
             NormalCard(
                 modifier = modifier
                     .fillMaxWidth()
-                    .padding(all = 4.dp),
+                    .padding(all = 4.dp)
+                    .then(borderModifier),
                 cornerRadius = 8.dp,
-                containerColor = selectedColor,
+                containerColor = if (isSelected) {
+                    LegadoTheme.colorScheme.secondaryContainer
+                } else {
+                    containerColor
+                },
                 onClick = onClick,
                 onLongClick = onLongClick
             ) {
@@ -177,7 +232,11 @@ fun BookshelfItem(
                     ) {
                         AppText(
                             text = title,
-                            style = LegadoTheme.typography.titleMediumEmphasized,
+                            style = if (titleColor != null) {
+                                LegadoTheme.typography.titleMediumEmphasized.copy(color = titleColor)
+                            } else {
+                                LegadoTheme.typography.titleMediumEmphasized
+                            },
                             maxLines = 1,
                             overflow = TextOverflow.Ellipsis,
                             modifier = Modifier.weight(1f)
@@ -193,13 +252,22 @@ fun BookshelfItem(
                             )
                         }
                         if (!isCompact) {
-                            desc?.let {
+                            if (descAnnotated != null) {
                                 AppText(
-                                    text = it,
+                                    text = descAnnotated,
                                     style = LegadoTheme.typography.labelSmallEmphasized,
-                                    maxLines = 1,
+                                    maxLines = descMaxLines,
                                     overflow = TextOverflow.Ellipsis
                                 )
+                            } else {
+                                desc?.let {
+                                    AppText(
+                                        text = it,
+                                        style = LegadoTheme.typography.labelSmallEmphasized,
+                                        maxLines = descMaxLines,
+                                        overflow = TextOverflow.Ellipsis
+                                    )
+                                }
                             }
                         }
                         extra?.let {
@@ -210,6 +278,7 @@ fun BookshelfItem(
                         }
                     }
                 }
+                bottomContent?.invoke()
             }
             if (BookshelfConfig.bookshelfShowDivider)
                 HorizontalDivider(
@@ -232,7 +301,6 @@ fun BookGroupCover(
         modifier = modifier
             .aspectRatio(5f / 7f)
             .clip(RoundedCornerShape(4.dp))
-            .background(LegadoTheme.colorScheme.surfaceContainer)
     ) {
         if (!coverPath.isNullOrBlank()) {
             CoilBookCover(
@@ -326,7 +394,7 @@ fun BookGroupCover(
 
 @Composable
 fun BookGroupItemGrid(
-    group: BookGroup,
+    group: BookGroupUi,
     previewBooks: List<BookShelfItem>,
     countText: String? = null,
     gridStyle: Int = 0,
@@ -363,7 +431,7 @@ fun BookGroupItemGrid(
 
 @Composable
 fun BookGroupItemList(
-    group: BookGroup,
+    group: BookGroupUi,
     previewBooks: List<BookShelfItem>,
     countText: String? = null,
     isCompact: Boolean = false,
@@ -375,13 +443,27 @@ fun BookGroupItemList(
     onClick: () -> Unit,
     onLongClick: (() -> Unit)?
 ) {
+    val firstBookName = previewBooks.firstOrNull()?.name
+    val primaryColor = MaterialTheme.colorScheme.primary
+    val descAnnotated = if (firstBookName != null) {
+        buildAnnotatedString {
+            append("最近阅读:")
+            withStyle(SpanStyle(color = primaryColor, fontWeight = FontWeight.Medium)) {
+                append("《$firstBookName》")
+            }
+        }
+    } else {
+        null
+    }
     BookshelfItem(
         isGrid = false,
         gridStyle = 0,
         isCompact = isCompact,
         cover = { BookGroupCover(books = previewBooks, coverPath = group.cover, modifier = it) },
         title = group.groupName,
+        titleColor = primaryColor,
         subTitle = countText,
+        descAnnotated = descAnnotated,
         titleSmallFont = titleSmallFont,
         titleCenter = titleCenter,
         titleMaxLines = titleMaxLines,
@@ -485,6 +567,52 @@ fun BookItem(
             book.author
         },
         desc = book.durChapterTitle ?: "",
+        bottomContent = if (layoutMode == 0 && BookshelfConfig.showBookIntro) {
+            {
+                val kindList = book.kind?.splitNotBlank(",", "\n")?.filter { it.isNotBlank() }
+                val intro = book.intro?.takeIf { it.isNotBlank() }
+                val customTagColors = if (ThemeConfig.enableCustomTagColors) ThemeConfig.getCustomTagColors() else emptyList()
+                if (!kindList.isNullOrEmpty()) {
+                        FlowRow(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(horizontal = 4.dp, vertical = 4.dp),
+                            horizontalArrangement = Arrangement.spacedBy(4.dp),
+                            verticalArrangement = Arrangement.spacedBy(4.dp)
+                        ) {
+                            kindList.forEachIndexed { index, label ->
+                                val colorPair = if (customTagColors.isNotEmpty()) {
+                                    customTagColors[index % customTagColors.size]
+                                } else {
+                                    null
+                                }
+                                TextCard(
+                                    text = label,
+                                    backgroundColor = if (colorPair != null && colorPair.bgColor != 0) Color(colorPair.bgColor) else LegadoTheme.colorScheme.surfaceContainerHighest,
+                                    contentColor = if (colorPair != null && colorPair.textColor != 0) Color(colorPair.textColor) else LegadoTheme.colorScheme.primary,
+                                    cornerRadius = 4.dp,
+                                    horizontalPadding = 6.dp,
+                                    verticalPadding = 2.dp,
+                                    textStyle = LegadoTheme.typography.labelSmall
+                                )
+                            }
+                        }
+                    }
+                if (intro != null) {
+                    val maxLines = if (BookshelfConfig.bookshelfIntroMaxLines == 0) Int.MAX_VALUE else BookshelfConfig.bookshelfIntroMaxLines
+                    AppText(
+                        text = intro,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = maxLines,
+                        overflow = TextOverflow.Ellipsis,
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(horizontal = 4.dp, vertical = 4.dp)
+                    )
+                }
+            }
+        } else null,
         extra = {
             if (BookshelfConfig.showLastUpdateTime && !book.isLocal) {
                 AppText(

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

@@ -25,7 +25,9 @@ data class BookShelfItem(
     val type: Int,
     val group: Long,
     val order: Int,
-    val canUpdate: Boolean = true
+    val canUpdate: Boolean = true,
+    val intro: String? = null,
+    val kind: String? = null
 ) {
     fun getDisplayCover() = if (customCoverUrl.isNullOrEmpty()) coverUrl else customCoverUrl
 

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

@@ -12,6 +12,10 @@ import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalConfiguration
 import androidx.compose.ui.res.stringArrayResource
@@ -19,8 +23,11 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
+import io.legado.app.ui.config.themeConfig.LabelColorManageSheet
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactSliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.CompactSwitchSettingItem
@@ -33,6 +40,7 @@ fun BookshelfConfigSheet(
 ) {
     val configuration = LocalConfiguration.current
     val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+    var showLabelColorManage by remember { mutableStateOf(false) }
 
     AppModalBottomSheet(
         title = stringResource(R.string.bookshelf_layout),
@@ -234,6 +242,38 @@ fun BookshelfConfigSheet(
                 onCheckedChange = { BookshelfConfig.showLastUpdateTime = it }
             )
 
+            CompactSwitchSettingItem(
+                title = "列表模式显示更多",
+                checked = BookshelfConfig.showBookIntro,
+                color = MaterialTheme.colorScheme.surface,
+                onCheckedChange = { BookshelfConfig.showBookIntro = it }
+            )
+
+            AnimatedVisibility(visible = BookshelfConfig.showBookIntro) {
+                CompactSliderSettingItem(
+                    title = "简介行数",
+                    description = if (BookshelfConfig.bookshelfIntroMaxLines == 0) "显示全部简介" else "显示 ${BookshelfConfig.bookshelfIntroMaxLines} 行简介",
+                    value = BookshelfConfig.bookshelfIntroMaxLines.toFloat(),
+                    valueRange = 0f..10f,
+                    steps = 10,
+                    onValueChange = { BookshelfConfig.bookshelfIntroMaxLines = it.toInt() }
+                )
+                CompactSwitchSettingItem(
+                    title = "自定义标签颜色",
+                    checked = ThemeConfig.enableCustomTagColors,
+                    color = MaterialTheme.colorScheme.surface,
+                    onCheckedChange = { ThemeConfig.enableCustomTagColors = it }
+                )
+
+                AnimatedVisibility(visible = ThemeConfig.enableCustomTagColors) {
+                    CompactClickableSettingItem(
+                        title = "管理标签颜色",
+                        color = MaterialTheme.colorScheme.surface,
+                        onClick = { showLabelColorManage = true }
+                    )
+                }
+            }
+
             CompactSwitchSettingItem(
                 title = stringResource(R.string.show_wait_up_count),
                 checked = BookshelfConfig.showWaitUpCount,
@@ -265,5 +305,10 @@ fun BookshelfConfigSheet(
                 onValueChange = { BookshelfConfig.bookshelfRefreshingLimit = it.toInt() }
             )
         }
+
+        LabelColorManageSheet(
+            show = showLabelColorManage,
+            onDismissRequest = { showLabelColorManage = false }
+        )
     }
 }

+ 19 - 32
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfScreen.kt

@@ -76,8 +76,8 @@ import androidx.compose.ui.window.Dialog
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.base.BaseRuleEvent
-import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.about.AppLogSheet
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.book.info.GroupSelectSheet
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
@@ -212,17 +212,6 @@ fun BookshelfScreen(
     val latestGroups by rememberUpdatedState(uiState.groups)
     val latestSelectedGroupId by rememberUpdatedState(uiState.selectedGroupId)
 
-    LaunchedEffect(uiState.groups, uiState.isSearch) {
-        if (!uiState.isSearch && uiState.groups.isNotEmpty()) {
-            val savedGroupId = BookshelfConfig.saveTabPosition
-            val savedGroupIndex = uiState.groups.indexOfFirst { it.groupId == savedGroupId }
-            if (savedGroupIndex >= 0 && savedGroupIndex != pagerState.currentPage) {
-                viewModel.changeGroup(savedGroupId)
-                pagerState.scrollToPage(savedGroupIndex)
-            }
-        }
-    }
-
     LaunchedEffect(pagerState) {
         snapshotFlow { pagerState.settledPage }
             .distinctUntilChanged()
@@ -709,10 +698,6 @@ fun BookshelfScreen(
                             paddingValues = paddingValues,
                             books = uiState.items,
                             uiState = uiState,
-                            bookshelfLayoutMode = bookshelfLayoutMode,
-                            bookshelfLayoutGrid = bookshelfLayoutGrid,
-                            bookshelfLayoutList = bookshelfLayoutList,
-                            isEditMode = isEditMode,
                             selectedBookUrls = selectedBookUrls,
                             canReorderBooks = false,
                             onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
@@ -725,6 +710,7 @@ fun BookshelfScreen(
                             onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
                             onBookClick = onBookClick,
                             onBookLongClick = onBookLongClick,
+                            isCurrentPage = true,
                             sharedTransitionScope = sharedTransitionScope,
                             animatedVisibilityScope = animatedVisibilityScope,
                         )
@@ -732,17 +718,15 @@ fun BookshelfScreen(
                         HorizontalPager(
                             state = pagerState,
                             modifier = Modifier.fillMaxSize(),
-                            beyondViewportPageCount = 1,
+                            beyondViewportPageCount = 2,
                             key = { if (it < uiState.groups.size) uiState.groups[it].groupId else it }
                         ) { pageIndex ->
                             val group = uiState.groups.getOrNull(pageIndex)
                             if (group != null) {
                                 val isSelectedGroup = group.groupId == uiState.selectedGroupId
-                                val books = if (isSelectedGroup) {
-                                    uiState.items
-                                } else {
-                                    emptyList()
-                                }
+                                val books = uiState.allGroupBooks[group.groupId]
+                                    ?: if (isSelectedGroup) uiState.items
+                                    else emptyList()
                                 val canReorderBooks = isEditMode &&
                                         !uiState.isSearch &&
                                         (group.bookSort.takeIf { it >= 0 }
@@ -752,10 +736,6 @@ fun BookshelfScreen(
                                     paddingValues = paddingValues,
                                     books = books,
                                     uiState = uiState,
-                                    bookshelfLayoutMode = bookshelfLayoutMode,
-                                    bookshelfLayoutGrid = bookshelfLayoutGrid,
-                                    bookshelfLayoutList = bookshelfLayoutList,
-                                    isEditMode = isEditMode,
                                     selectedBookUrls = selectedBookUrls,
                                     canReorderBooks = canReorderBooks,
                                     onToggleBookSelection = { toggleBookSelection(it.bookUrl) },
@@ -788,6 +768,7 @@ fun BookshelfScreen(
                                     onGlobalSearch = { onNavigateToSearch(uiState.searchKey.trim()) },
                                     onBookClick = onBookClick,
                                     onBookLongClick = onBookLongClick,
+                                    isCurrentPage = isSelectedGroup,
                                     sharedTransitionScope = sharedTransitionScope,
                                     animatedVisibilityScope = animatedVisibilityScope,
                                 )
@@ -1022,10 +1003,6 @@ fun BookshelfPage(
     paddingValues: PaddingValues,
     books: List<BookShelfItem>,
     uiState: BookshelfUiState,
-    bookshelfLayoutMode: Int,
-    bookshelfLayoutGrid: Int,
-    bookshelfLayoutList: Int,
-    isEditMode: Boolean,
     selectedBookUrls: Set<String>,
     canReorderBooks: Boolean,
     onToggleBookSelection: (BookShelfItem) -> Unit,
@@ -1038,10 +1015,12 @@ fun BookshelfPage(
     onGlobalSearch: () -> Unit,
     onBookClick: (BookShelfItem) -> Unit,
     onBookLongClick: (BookShelfItem) -> Unit,
+    isCurrentPage: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
 ) {
     if (books.isEmpty()) {
+        if (!isCurrentPage) return
         if (uiState.isSearch) {
             EmptyMessage(
                 modifier = Modifier
@@ -1068,6 +1047,14 @@ fun BookshelfPage(
         return
     }
 
+    val configuration = LocalConfiguration.current
+    val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+    val bookshelfLayoutMode =
+        if (isLandscape) BookshelfConfig.bookshelfLayoutModeLandscape else BookshelfConfig.bookshelfLayoutModePortrait
+    val bookshelfLayoutGrid =
+        if (isLandscape) BookshelfConfig.bookshelfLayoutGridLandscape else BookshelfConfig.bookshelfLayoutGridPortrait
+    val bookshelfLayoutList =
+        if (isLandscape) BookshelfConfig.bookshelfLayoutListLandscape else BookshelfConfig.bookshelfLayoutListPortrait
     val columns = if (bookshelfLayoutMode == 0) bookshelfLayoutList else bookshelfLayoutGrid
     val isGridMode = bookshelfLayoutMode != 0
     val totalHorizontalPadding =
@@ -1148,7 +1135,7 @@ fun BookshelfPage(
                     animatedVisibilityScope = animatedVisibilityScope,
                     sharedCoverKey = bookCoverSharedElementKey(book.bookUrl),
                     onClick = {
-                        if (isEditMode) {
+                        if (uiState.isEditMode) {
                             onToggleBookSelection(book)
                         } else {
                             onBookClick(book)
@@ -1158,7 +1145,7 @@ fun BookshelfPage(
                         null
                     } else {
                         {
-                            if (isEditMode) {
+                            if (uiState.isEditMode) {
                                 onToggleBookSelection(book)
                             } else {
                                 onBookLongClick(book)

+ 8 - 4
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfUiState.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.main.bookshelf
 
+import androidx.compose.runtime.Stable
 import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.widget.components.list.ListUiState
 import kotlinx.collections.immutable.ImmutableList
@@ -9,8 +10,9 @@ import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentMapOf
 import kotlinx.collections.immutable.persistentSetOf
 
+@Stable
 data class BookshelfGroupSelectorState(
-    val groups: ImmutableList<BookGroup> = persistentListOf(),
+    val groups: ImmutableList<BookGroupUi> = persistentListOf(),
     val selectedGroupIndex: Int = 0,
     val selectedGroupId: Long = BookGroup.IdAll
 )
@@ -27,14 +29,15 @@ sealed interface BookshelfOverlay {
     data object BatchDownloadConfirmDialog : BookshelfOverlay
 }
 
+@Stable
 data class BookshelfUiState(
     override val items: ImmutableList<BookShelfItem> = persistentListOf(),
     override val selectedIds: ImmutableSet<Any> = persistentSetOf(),
     override val searchKey: String = "",
     override val isSearch: Boolean = false,
     override val isLoading: Boolean = false,
-    val groups: ImmutableList<BookGroup> = persistentListOf(),
-    val allGroups: ImmutableList<BookGroup> = persistentListOf(),
+    val groups: ImmutableList<BookGroupUi> = persistentListOf(),
+    val allGroups: ImmutableList<BookGroupUi> = persistentListOf(),
     val groupPreviews: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf(),
     val groupBookCounts: ImmutableMap<Long, Int> = persistentMapOf(),
     val currentGroupBookCount: Int = 0,
@@ -56,5 +59,6 @@ data class BookshelfUiState(
     val subtitle: String? = null,
     val currentGroupName: String? = null,
     val draggingBooks: ImmutableList<BookShelfItem>? = null,
-    val pendingSavedBooks: ImmutableList<BookShelfItem>? = null
+    val pendingSavedBooks: ImmutableList<BookShelfItem>? = null,
+    val allGroupBooks: ImmutableMap<Long, ImmutableList<BookShelfItem>> = persistentMapOf()
 ) : ListUiState<BookShelfItem>

+ 93 - 110
app/src/main/java/io/legado/app/ui/main/bookshelf/BookshelfViewModel.kt

@@ -73,10 +73,13 @@ import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.coroutines.flow.stateIn
@@ -158,10 +161,6 @@ class BookshelfViewModel(
     val allGroupsFlow: StateFlow<List<BookGroup>> = bookGroupRepository.flowAll()
         .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
 
-    private val allBooksFlow = appDb.bookDao.flowBookShelf()
-        .distinctUntilChanged()
-        .flowOn(Dispatchers.Default)
-
     private data class GroupPreviewState(
         val previews: ImmutableMap<Long, ImmutableList<BookShelfItem>>,
         val counts: ImmutableMap<Long, Int>,
@@ -173,7 +172,7 @@ class BookshelfViewModel(
         groupIdFlow
     ) { groups, selectedGroupId ->
         BookshelfGroupSelectorState(
-            groups = groups.toImmutableList(),
+            groups = groups.map { it.toBookGroupUi() }.toImmutableList(),
             selectedGroupIndex = groups.indexOfFirst { it.groupId == selectedGroupId }
                 .coerceAtLeast(0),
             selectedGroupId = selectedGroupId
@@ -197,6 +196,25 @@ class BookshelfViewModel(
             }
         }.distinctUntilChanged().flowOn(Dispatchers.Default)
 
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val allGroupBooksFlow: StateFlow<Map<Long, List<BookShelfItem>>> = combine(
+        groupsFlow, sortConfigFlow
+    ) { groups, sortConfig ->
+        groups to sortConfig
+    }.flatMapLatest { (groups, sortConfig) ->
+        if (groups.isEmpty()) {
+            flowOf(emptyMap())
+        } else {
+            val flows = groups.map { group ->
+                appDb.bookDao.flowBookShelfByGroup(group.groupId).map { books ->
+                    group.groupId to sortBooks(books, group, sortConfig)
+                }
+            }
+            combine(flows) { it.toMap() }
+        }
+    }.flowOn(Dispatchers.Default)
+        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
+
     private val visibleBooksFlow = combine(
         booksFlow,
         searchKeyFlow,
@@ -213,15 +231,45 @@ class BookshelfViewModel(
         selectedBookUrls.intersect(visibleBookUrls)
     }.distinctUntilChanged()
 
-    private val groupPreviewsFlow =
-        combine(
-            groupsFlow,
-            allBooksFlow,
-            bookGroupStyleFlow,
-            sortConfigFlow
-        ) { groups, allBooks, bookGroupStyle, sortConfig ->
-            buildGroupPreviewState(groups, allBooks, bookGroupStyle, sortConfig)
-        }.distinctUntilChanged().flowOn(Dispatchers.Default)
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private val groupPreviewsFlow = combine(
+        groupsFlow,
+        bookGroupStyleFlow,
+        appDb.bookDao.flowSystemGroupCounts()
+    ) { groups, bookGroupStyle, systemCounts ->
+        Triple(groups, bookGroupStyle, systemCounts.associate { it.groupId to it.count })
+    }.flatMapLatest { (groups, bookGroupStyle, systemCountsMap) ->
+        if (bookGroupStyle !in 2..3) {
+            appDb.bookDao.flowAllBookShelfCount().map { count ->
+                GroupPreviewState(persistentMapOf(), persistentMapOf(), count)
+            }
+        } else if (groups.isEmpty()) {
+            flowOf(GroupPreviewState(persistentMapOf(), persistentMapOf(), 0))
+        } else {
+            val groupFlows = groups.map { group ->
+                val countFlow: Flow<Int> = if (group.groupId > 0) {
+                    appDb.bookDao.flowUserGroupBookCount(group.groupId)
+                } else {
+                    flowOf(systemCountsMap[group.groupId] ?: 0)
+                }
+                val previewFlow = appDb.bookDao.flowGroupPreview(group.groupId)
+                combine(countFlow, previewFlow) { count, preview ->
+                    Triple(group.groupId, count, preview)
+                }
+            }
+            combine(groupFlows) { results ->
+                var previews = persistentMapOf<Long, ImmutableList<BookShelfItem>>()
+                var counts = persistentMapOf<Long, Int>()
+                var allBookCount = 0
+                results.forEach { (groupId, count, preview) ->
+                    counts = counts.put(groupId, count)
+                    previews = previews.put(groupId, preview.toImmutableList())
+                    if (groupId == BookGroup.IdAll) allBookCount = count
+                }
+                GroupPreviewState(previews, counts, allBookCount)
+            }
+        }
+    }.distinctUntilChanged().flowOn(Dispatchers.Default)
 
     private val coreInternalStateFlow = combine(
         groupIdFlow,
@@ -309,16 +357,28 @@ class BookshelfViewModel(
     }
 
     private val dataStateFlow = combine(
-        booksFlow,
-        groupsFlow,
-        allGroupsFlow,
-        groupPreviewsFlow,
-        internalStateFlow
-    ) { books, groups, allGroups, previews, internal ->
-        BookshelfDataState(books, groups, allGroups, previews, internal)
+        combine(
+            booksFlow,
+            groupsFlow,
+            allGroupsFlow,
+            groupPreviewsFlow,
+            internalStateFlow
+        ) { books, groups, allGroups, previews, internal ->
+            BookshelfDataCore(books, groups, allGroups, previews, internal)
+        },
+        allGroupBooksFlow
+    ) { core, allGroupBooks ->
+        BookshelfDataState(
+            books = core.books,
+            groups = core.groups.map { it.toBookGroupUi() },
+            allGroups = core.allGroups.map { it.toBookGroupUi() },
+            previews = core.previews,
+            internal = core.internal,
+            allGroupBooks = allGroupBooks
+        )
     }
 
-    private data class BookshelfDataState(
+    private data class BookshelfDataCore(
         val books: List<BookShelfItem>,
         val groups: List<BookGroup>,
         val allGroups: List<BookGroup>,
@@ -326,6 +386,15 @@ class BookshelfViewModel(
         val internal: InternalState
     )
 
+    private data class BookshelfDataState(
+        val books: List<BookShelfItem>,
+        val groups: List<BookGroupUi>,
+        val allGroups: List<BookGroupUi>,
+        val previews: GroupPreviewState,
+        val internal: InternalState,
+        val allGroupBooks: Map<Long, List<BookShelfItem>>
+    )
+
     val uiState: StateFlow<BookshelfUiState> = combine(
         dataStateFlow,
         interactionStateFlow
@@ -383,7 +452,8 @@ class BookshelfViewModel(
             },
             currentGroupName = currentGroupName,
             draggingBooks = interaction.draggingBooks?.toImmutableList(),
-            pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList()
+            pendingSavedBooks = interaction.pendingSavedBooks?.toImmutableList(),
+            allGroupBooks = data.allGroupBooks.mapValues { it.value.toImmutableList() }.toImmutableMap()
         )
     }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BookshelfUiState())
 
@@ -495,93 +565,6 @@ class BookshelfViewModel(
         }
     }
 
-    private fun buildGroupPreviewState(
-        groups: List<BookGroup>,
-        allBooks: List<BookShelfItem>,
-        bookGroupStyle: Int,
-        sortConfig: BookshelfSortConfig
-    ): GroupPreviewState {
-        if (bookGroupStyle !in 2..3) {
-            return GroupPreviewState(persistentMapOf(), persistentMapOf(), allBooks.size)
-        }
-
-        val buckets = HashMap<Long, MutableList<BookShelfItem>>(groups.size)
-        groups.forEach { group ->
-            buckets[group.groupId] = ArrayList()
-        }
-
-        val userGroups = groups.filter { it.groupId > 0 }
-        val sumUserGroupIds = userGroups.sumOf { it.groupId }
-
-        fun add(groupId: Long, book: BookShelfItem) {
-            buckets[groupId]?.add(book)
-        }
-
-        allBooks.forEach { book ->
-            add(BookGroup.IdAll, book)
-            if (book.isRootGroupBook(sumUserGroupIds)) add(BookGroup.IdRoot, book)
-            if (book.isLocal) add(BookGroup.IdLocal, book)
-            if (book.isAudio) add(BookGroup.IdAudio, book)
-            if (book.isNetNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdNetNone, book)
-            if (book.isLocalNoneGroupBook(sumUserGroupIds)) add(BookGroup.IdLocalNone, book)
-            if (book.isImage) add(BookGroup.IdManga, book)
-            if ((book.type and BookType.text) > 0) add(BookGroup.IdText, book)
-            if ((book.type and BookType.updateError) > 0) add(BookGroup.IdError, book)
-            if (book.durChapterIndex == 0 && book.durChapterPos == 0) {
-                add(BookGroup.IdUnread, book)
-            }
-            if (book.totalChapterNum > 0 &&
-                book.durChapterIndex > 0 &&
-                book.durChapterIndex < book.totalChapterNum - 1
-            ) {
-                add(BookGroup.IdReading, book)
-            }
-            if (book.totalChapterNum > 0 && book.durChapterIndex >= book.totalChapterNum - 1) {
-                add(BookGroup.IdReadFinished, book)
-            }
-            userGroups.forEach { group ->
-                if ((book.group and group.groupId) != 0L) {
-                    add(group.groupId, book)
-                }
-            }
-        }
-
-        val previews = HashMap<Long, ImmutableList<BookShelfItem>>(groups.size)
-        val counts = HashMap<Long, Int>(groups.size)
-        groups.forEach { group ->
-            val groupBooks = buckets[group.groupId].orEmpty()
-            counts[group.groupId] = groupBooks.size
-            previews[group.groupId] = buildGroupPreview(sortBooks(groupBooks, group, sortConfig))
-        }
-        return GroupPreviewState(previews.toImmutableMap(), counts.toImmutableMap(), allBooks.size)
-    }
-
-    private fun BookShelfItem.isRootGroupBook(sumUserGroupIds: Long): Boolean {
-        return (type and BookType.text) > 0 &&
-                (type and BookType.local) == 0 &&
-                (sumUserGroupIds and group) == 0L
-    }
-
-    private fun BookShelfItem.isNetNoneGroupBook(sumUserGroupIds: Long): Boolean {
-        return (type and BookType.audio) == 0 &&
-                (type and BookType.local) == 0 &&
-                (sumUserGroupIds and group) == 0L
-    }
-
-    private fun BookShelfItem.isLocalNoneGroupBook(sumUserGroupIds: Long): Boolean {
-        return (type and BookType.local) > 0 &&
-                (sumUserGroupIds and group) == 0L
-    }
-
-    private fun buildGroupPreview(sortedBooks: List<BookShelfItem>): ImmutableList<BookShelfItem> {
-        val booksWithCover = sortedBooks.filter { it.getDisplayCover() != null }
-        return if (booksWithCover.size >= 4) {
-            booksWithCover.take(4)
-        } else {
-            (booksWithCover + sortedBooks.filter { it.getDisplayCover() == null }).take(4)
-        }.toImmutableList()
-    }
-
     fun changeGroup(groupId: Long) {
         if (groupIdFlow.value != groupId) {
             groupIdFlow.value = groupId

+ 24 - 1
app/src/main/java/io/legado/app/ui/main/explore/ExploreScreen.kt

@@ -5,6 +5,7 @@ import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.tween
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.combinedClickable
 import androidx.compose.foundation.layout.Arrangement
@@ -57,6 +58,7 @@ import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.book.search.SearchScope
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.login.SourceLoginActivity
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
@@ -286,12 +288,22 @@ fun ExploreScreen(
                     .align(Alignment.TopStart)
                     .padding(top = paddingValues.calculateTopPadding() + 4.dp, start = 8.dp)
             ) { item ->
+                val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder
+                val borderWidth = ThemeConfig.containerBorderWidth.dp
+                val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+                    Color(ThemeConfig.containerBorderColor)
+                } else {
+                    LegadoTheme.colorScheme.outline
+                }
                 TextCard(
                     text = item.bookSourceName,
                     textStyle = LegadoTheme.typography.labelMediumEmphasized,
                     cornerRadius = 12.dp,
                     horizontalPadding = 12.dp,
                     verticalPadding = 8.dp,
+                    border = if (enableBorder) {
+                        BorderStroke(borderWidth, borderColor)
+                    } else null,
                     onClick = {
                         scope.launch {
                             val index =
@@ -362,12 +374,23 @@ fun ExploreSourceHeader(
         label = "CardColor"
     )
 
+    val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder
+    val borderWidth = ThemeConfig.containerBorderWidth.dp
+    val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+        Color(ThemeConfig.containerBorderColor)
+    } else {
+        LegadoTheme.colorScheme.outline
+    }
+
     GlassCard(
         modifier = modifier
             .fillMaxWidth()
             .padding(vertical = 4.dp),
         cornerRadius = 12.dp,
-        containerColor = containerColor
+        containerColor = containerColor,
+        border = if (enableBorder) {
+            BorderStroke(borderWidth, borderColor)
+        } else null
     ) {
         ListItem(
             modifier = Modifier

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

@@ -1,6 +1,7 @@
 package io.legado.app.ui.rss.article
 
 import androidx.compose.foundation.background
+import androidx.compose.foundation.border
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
@@ -64,6 +65,9 @@ import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
 import io.legado.app.utils.toastOnUi
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.flowOn
@@ -92,6 +96,7 @@ fun RssArticlesPage(
     rssUrl: String?,
     rssSource: RssSource?,
     viewModel: RssArticlesViewModel,
+    searchKey: String? = null,
     onRead: (RssArticle) -> Unit,
     paddingValues: PaddingValues = PaddingValues(0.dp),
     modifier: Modifier = Modifier
@@ -99,9 +104,10 @@ fun RssArticlesPage(
     val context = LocalContext.current
     val layout = remember(articleStyle) { articleStyle.toRssArticleLayout() }
     val loadState by viewModel.loadState.collectAsStateWithLifecycle()
+    val isPreload = remember(rssSource) { rssSource?.preload == true }
 
-    LaunchedEffect(sortName, sortUrl) {
-        viewModel.init(sortName, sortUrl)
+    LaunchedEffect(sortName, sortUrl, searchKey) {
+        viewModel.init(sortName, sortUrl, searchKey)
     }
 
     val articleFlow = remember(rssUrl, sortName) {
@@ -117,7 +123,11 @@ fun RssArticlesPage(
     val articles by articleFlow.collectAsStateWithLifecycle(initialValue = emptyList())
 
     LaunchedEffect(rssSource?.sourceUrl, sortName, sortUrl) {
-        rssSource?.let(viewModel::loadArticles)
+        rssSource?.let { source ->
+            if (isPreload) {
+                viewModel.loadArticles(source)
+            }
+        }
     }
 
     LaunchedEffect(loadState.errorMessage) {
@@ -142,6 +152,7 @@ fun RssArticlesPage(
                 LoadMoreDetector(
                     state = listState,
                     enabled = loadState.canLoadMore,
+                    preloadThreshold = if (isPreload) 5 else 3,
                     onLoadMore = { rssSource?.let(viewModel::loadMore) }
                 )
                 LazyColumn(
@@ -174,6 +185,7 @@ fun RssArticlesPage(
                 GridLoadMoreDetector(
                     state = gridState,
                     enabled = loadState.canLoadMore,
+                    preloadThreshold = if (isPreload) 5 else 3,
                     onLoadMore = { rssSource?.let(viewModel::loadMore) }
                 )
                 LazyVerticalGrid(
@@ -208,6 +220,7 @@ fun RssArticlesPage(
                 StaggeredLoadMoreDetector(
                     state = staggeredState,
                     enabled = loadState.canLoadMore,
+                    preloadThreshold = if (isPreload) 5 else 3,
                     onLoadMore = { rssSource?.let(viewModel::loadMore) }
                 )
                 LazyVerticalStaggeredGrid(
@@ -244,13 +257,14 @@ fun RssArticlesPage(
 private fun LoadMoreDetector(
     state: LazyListState,
     enabled: Boolean,
+    preloadThreshold: Int = 3,
     onLoadMore: () -> Unit
 ) {
     LaunchedEffect(state, enabled) {
         snapshotFlow {
             val info = state.layoutInfo
             val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
-            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold
         }.collect { shouldLoad ->
             if (shouldLoad) onLoadMore()
         }
@@ -261,13 +275,14 @@ private fun LoadMoreDetector(
 private fun GridLoadMoreDetector(
     state: LazyGridState,
     enabled: Boolean,
+    preloadThreshold: Int = 3,
     onLoadMore: () -> Unit
 ) {
     LaunchedEffect(state, enabled) {
         snapshotFlow {
             val info = state.layoutInfo
             val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1
-            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold
         }.collect { shouldLoad ->
             if (shouldLoad) onLoadMore()
         }
@@ -278,13 +293,14 @@ private fun GridLoadMoreDetector(
 private fun StaggeredLoadMoreDetector(
     state: LazyStaggeredGridState,
     enabled: Boolean,
+    preloadThreshold: Int = 3,
     onLoadMore: () -> Unit
 ) {
     LaunchedEffect(state, enabled) {
         snapshotFlow {
             val info = state.layoutInfo
             val lastVisible = info.visibleItemsInfo.maxOfOrNull { it.index } ?: -1
-            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - 3
+            enabled && info.totalItemsCount > 0 && lastVisible >= info.totalItemsCount - preloadThreshold
         }.collect { shouldLoad ->
             if (shouldLoad) onLoadMore()
         }
@@ -340,11 +356,22 @@ private fun RssArticleItem(
         RssArticleLayout.Waterfall -> 3
     }
 
+    // 边框设置
+    val borderModifier = if (ThemeConfig.enableContainerBorder) {
+        Modifier.border(
+            width = Dp(ThemeConfig.containerBorderWidth),
+            color = Color(ThemeConfig.containerBorderColor),
+            shape = RoundedCornerShape(12.dp)
+        )
+    } else {
+        Modifier
+    }
+
     GlassCard(
         onClick = { onClick(article) },
         cornerRadius = 12.dp,
         containerColor = containerColor,
-        modifier = Modifier.fillMaxWidth()
+        modifier = Modifier.fillMaxWidth().then(borderModifier)
     ) {
         when (layout) {
             RssArticleLayout.List -> {

+ 8 - 5
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt

@@ -24,21 +24,24 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
     private var nextPageUrl: String? = null
     var sortName: String = ""
     var sortUrl: String = ""
+    var searchKey: String? = null
     var page = 1
 
     fun init(bundle: Bundle?) {
         bundle?.let {
             init(
                 sortName = it.getString("sortName") ?: "",
-                sortUrl = it.getString("sortUrl") ?: ""
+                sortUrl = it.getString("sortUrl") ?: "",
+                searchKey = it.getString("searchKey")
             )
         }
     }
 
-    fun init(sortName: String, sortUrl: String) {
-        if (this.sortName == sortName && this.sortUrl == sortUrl) return
+    fun init(sortName: String, sortUrl: String, searchKey: String? = null) {
+        if (this.sortName == sortName && this.sortUrl == sortUrl && this.searchKey == searchKey) return
         this.sortName = sortName
         this.sortUrl = sortUrl
+        this.searchKey = searchKey
         page = 1
         nextPageUrl = null
         _loadState.value = RssArticlesLoadState()
@@ -53,7 +56,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
         )
         page = 1
         order = System.currentTimeMillis()
-        Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page).onSuccess(IO) {
+        Rss.getArticles(viewModelScope, sortName, sortUrl, rssSource, page, searchKey).onSuccess(IO) {
             nextPageUrl = it.second
             val articles = it.first
             articles.forEach { rssArticle ->
@@ -97,7 +100,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
         )
 
         page++
-        Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page).onSuccess(IO) {
+        Rss.getArticles(viewModelScope, sortName, pageUrl, rssSource, page, searchKey).onSuccess(IO) {
             nextPageUrl = it.second
             loadMoreSuccess(it.first)
         }.onError {

+ 274 - 0
app/src/main/java/io/legado/app/ui/rss/article/RssStartPage.kt

@@ -0,0 +1,274 @@
+package io.legado.app.ui.rss.article
+
+import android.annotation.SuppressLint
+import android.webkit.ConsoleMessage
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.compose.LocalActivity
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import io.legado.app.data.entities.RssSource
+import io.legado.app.help.WebCacheManager
+import io.legado.app.help.config.AppConfig
+import io.legado.app.help.http.CookieManager
+import io.legado.app.help.webView.WebJsExtensions
+import java.io.ByteArrayInputStream
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun RssStartPage(
+    rssSource: RssSource?,
+    onNavigateToArticles: (sortUrl: String?) -> Unit,
+    modifier: Modifier = Modifier
+) {
+    val context = LocalContext.current
+    val activity = LocalActivity.current as? AppCompatActivity
+    var progress by remember { mutableIntStateOf(0) }
+    var isLoading by remember { mutableStateOf(true) }
+    var webViewRef by remember { mutableStateOf<WebView?>(null) }
+
+    val startHtml = rssSource?.startHtml
+    val startStyle = rssSource?.startStyle ?: rssSource?.style
+    val startJs = rssSource?.startJs
+    val preloadJs = rssSource?.preloadJs
+    val hasPreloadJs = !preloadJs.isNullOrBlank()
+
+    LaunchedEffect(startHtml) {
+        if (startHtml.isNullOrBlank()) {
+            onNavigateToArticles(null)
+        }
+    }
+
+    if (startHtml.isNullOrBlank()) {
+        return
+    }
+
+    DisposableEffect(Unit) {
+        onDispose {
+            webViewRef?.destroy()
+        }
+    }
+
+    Box(
+        modifier = modifier
+            .fillMaxSize()
+            .windowInsetsPadding(WindowInsets.statusBars)
+    ) {
+        if (isLoading && progress < 100) {
+            LinearProgressIndicator(
+                progress = { progress / 100f },
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(top = 4.dp)
+            )
+        }
+
+        AndroidView(
+            factory = { ctx ->
+                WebView(ctx).apply {
+                    webViewRef = this
+
+                    settings.apply {
+                        javaScriptEnabled = true
+                        domStorageEnabled = true
+                        mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+                        useWideViewPort = true
+                        loadWithOverviewMode = true
+                        userAgentString = AppConfig.userAgent
+                        cacheMode = if (rssSource?.cacheFirst == true) {
+                            WebSettings.LOAD_CACHE_ELSE_NETWORK
+                        } else {
+                            WebSettings.LOAD_DEFAULT
+                        }
+                    }
+
+                    CookieManager.applyToWebView(rssSource?.sourceUrl.orEmpty())
+
+                    if (hasPreloadJs && activity != null && rssSource != null) {
+                        val webJsExtensions = WebJsExtensions(
+                            rssSource,
+                            activity,
+                            this,
+                            callback = object : WebJsExtensions.Callback {
+                                override fun upConfig(config: String) {}
+                                override fun onNavigateToArticles(sortUrl: String?) {
+                                    activity.runOnUiThread {
+                                        onNavigateToArticles(sortUrl)
+                                    }
+                                }
+                            }
+                        )
+                        addJavascriptInterface(webJsExtensions, WebJsExtensions.nameJava)
+                        addJavascriptInterface(rssSource, WebJsExtensions.nameSource)
+                        addJavascriptInterface(WebCacheManager, WebJsExtensions.nameCache)
+                    }
+
+                    addJavascriptInterface(object {
+                        @android.webkit.JavascriptInterface
+                        fun navigateToArticles() {
+                            activity?.runOnUiThread {
+                                onNavigateToArticles(null)
+                            }
+                        }
+
+                        @android.webkit.JavascriptInterface
+                        fun getSourceUrl(): String {
+                            return rssSource?.sourceUrl.orEmpty()
+                        }
+
+                        @android.webkit.JavascriptInterface
+                        fun getSourceName(): String {
+                            return rssSource?.sourceName.orEmpty()
+                        }
+                    }, "AndroidBridge")
+
+                    var jsInjected = false
+
+                    webViewClient = object : WebViewClient() {
+                        override fun onPageFinished(view: WebView?, url: String?) {
+                            super.onPageFinished(view, url)
+                            isLoading = false
+                        }
+
+                        override fun shouldInterceptRequest(
+                            view: WebView?,
+                            request: WebResourceRequest
+                        ): WebResourceResponse? {
+                            val url = request.url.toString()
+                            if (!jsInjected && url == WebJsExtensions.nameUrl) {
+                                jsInjected = true
+                                val jsContent = buildString {
+                                    append("(() => {")
+                                    append(WebJsExtensions.JS_INJECTION)
+                                    if (!preloadJs.isNullOrBlank()) {
+                                        append("\n")
+                                        append(preloadJs)
+                                    }
+                                    append("\n})();")
+                                }
+                                return WebResourceResponse(
+                                    "text/javascript",
+                                    "utf-8",
+                                    ByteArrayInputStream(jsContent.toByteArray())
+                                )
+                            }
+                            return super.shouldInterceptRequest(view, request)
+                        }
+                    }
+
+                    webChromeClient = object : WebChromeClient() {
+                        override fun onProgressChanged(view: WebView?, newProgress: Int) {
+                            progress = newProgress
+                        }
+
+                        override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
+                            if (rssSource?.showWebLog == true) {
+                                consoleMessage?.message()?.let { message ->
+                                    io.legado.app.constant.AppLog.put("StartPage: $message")
+                                }
+                            }
+                            return true
+                        }
+                    }
+
+                    val htmlContent = buildStartPageHtml(startHtml, startStyle, startJs, hasPreloadJs)
+                    loadDataWithBaseURL(
+                        rssSource?.sourceUrl,
+                        htmlContent,
+                        "text/html",
+                        "utf-8",
+                        null
+                    )
+                }
+            },
+            modifier = Modifier.fillMaxSize()
+        )
+    }
+}
+
+private fun buildStartPageHtml(
+    html: String,
+    style: String?,
+    startJs: String?,
+    hasPreloadJs: Boolean
+): String {
+    val styleContent = style?.takeIf { it.isNotBlank() }?.let {
+        "<style>$it</style>"
+    } ?: "<style>img{max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}</style>"
+
+    val jsScript = startJs?.takeIf { it.isNotBlank() }?.let {
+        "<script>$it</script>"
+    } ?: ""
+
+    val preloadScript = if (hasPreloadJs) {
+        WebJsExtensions.JS_URL
+    } else {
+        ""
+    }
+
+    val htmlBuilder = StringBuilder()
+
+    val headIndex = html.indexOf("<head>")
+    if (headIndex >= 0) {
+        htmlBuilder.append(html, 0, headIndex + 6)
+        htmlBuilder.append(preloadScript)
+        htmlBuilder.append(html, headIndex + 6, html.length)
+    } else {
+        htmlBuilder.append("<head>")
+        htmlBuilder.append(preloadScript)
+        htmlBuilder.append("</head>")
+        htmlBuilder.append(html)
+    }
+
+    val result = htmlBuilder.toString()
+
+    val styleEndIndex = result.indexOf("</style>")
+    return if (styleEndIndex >= 0) {
+        val insertIndex = styleEndIndex + 8
+        buildString {
+            append(result, 0, insertIndex)
+            append(styleContent)
+            append(result, insertIndex, result.length)
+            append(jsScript)
+        }
+    } else {
+        val headEndIndex = result.indexOf("</head>")
+        if (headEndIndex >= 0) {
+            buildString {
+                append(result, 0, headEndIndex)
+                append(styleContent)
+                append(result, headEndIndex, result.length)
+                append(jsScript)
+            }
+        } else {
+            buildString {
+                append(styleContent)
+                append(result)
+                append(jsScript)
+            }
+        }
+    }
+}

+ 12 - 2
app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.RssReadRecord
 import io.legado.app.data.entities.RssSource
 import io.legado.app.help.JsExtensions
+import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.ui.association.AddToBookshelfDialog
 import io.legado.app.ui.book.search.SearchActivity
 import io.legado.app.ui.login.SourceLoginActivity
@@ -72,10 +73,19 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
         activityRef.get()?.showDialogFragment(PhotoDialog(src, getSource()?.getKey()))
     }
 
+    @JvmOverloads
+    fun getStringList(rule: String?, mContent: Any? = null, isUrl: Boolean = false): List<String>? {
+        return AnalyzeRule(null, source = getSource()).getStringList(rule, mContent, isUrl)
+    }
+
+    @JvmOverloads
+    fun getString(ruleStr: String?, mContent: Any? = null, isUrl: Boolean = false): String {
+        return AnalyzeRule(null, source = getSource()).getString(ruleStr, mContent, isUrl)
+    }
 
     @JavascriptInterface
     @JvmOverloads
-    fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) {
+    open fun open(name: String, url: String? = null, title: String? = null, origin: String? = null) {
         val activity = activityRef.get() ?: return
         activity.lifecycleScope.launch(IO) {
             val source = getSource() ?: return@launch
@@ -151,7 +161,7 @@ open class RssJsExtensions(activity: AppCompatActivity?, source: BaseSource?) :
                         origin = sourceUrl,
                         readTime = System.currentTimeMillis()
                     )
-                    appDb.rssReadRecordDao.insertRecord(rssReadRecord) //留下历史记录
+                    appDb.rssReadRecordDao.insertRecord(rssReadRecord)
                     withContext(Main) {
                         activity.startActivity(
                             MainActivity.createRssReadIntent(

+ 65 - 9
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugActivity.kt

@@ -1,16 +1,17 @@
 package io.legado.app.ui.rss.source.debug
 
+import android.annotation.SuppressLint
 import android.os.Bundle
 import android.view.Menu
 import android.view.MenuItem
-import android.widget.SearchView
+import android.view.View
+import androidx.appcompat.widget.SearchView
 import androidx.activity.viewModels
 import androidx.lifecycle.lifecycleScope
 import io.legado.app.R
 import io.legado.app.base.VMBaseActivity
 import io.legado.app.databinding.ActivitySourceDebugBinding
-//import io.legado.app.lib.theme.accentColor
-//import io.legado.app.lib.theme.primaryColor
+import io.legado.app.help.source.sortUrls
 import io.legado.app.ui.widget.dialog.TextDialog
 import io.legado.app.utils.applyNavigationBarPadding
 import io.legado.app.utils.gone
@@ -19,6 +20,8 @@ import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.viewbindingdelegate.viewBinding
 import io.legado.app.utils.visible
 import kotlinx.coroutines.launch
+import splitties.views.onClick
+import splitties.views.onLongClick
 
 
 class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSourceDebugModel>() {
@@ -28,10 +31,12 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
 
     private val adapter by lazy { RssSourceDebugAdapter(this) }
 
+    @SuppressLint("SetTextI18n")
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         initRecyclerView()
         initSearchView()
+        initHelpView()
         viewModel.observe { state, msg ->
             lifecycleScope.launch {
                 adapter.addItem(msg)
@@ -41,7 +46,7 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
             }
         }
         viewModel.initData(intent.getStringExtra("key")) {
-            startDebug()
+            // initData 完成后的回调
         }
     }
 
@@ -59,21 +64,72 @@ class RssSourceDebugActivity : VMBaseActivity<ActivitySourceDebugBinding, RssSou
     }
 
     private fun initRecyclerView() {
-//        binding.recyclerView.setEdgeEffectColor(primaryColor)
         binding.recyclerView.adapter = adapter
         binding.recyclerView.applyNavigationBarPadding()
-//        binding.rotateLoading.loadingColor = accentColor
     }
 
     private fun initSearchView() {
-        binding.titleBar.findViewById<SearchView>(R.id.search_view).gone()
+        val searchView = binding.titleBar.findViewById<SearchView>(R.id.search_view)
+        searchView.visible()
+        searchView.isSubmitButtonEnabled = true
+        searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
+            override fun onQueryTextSubmit(query: String?): Boolean {
+                searchView.clearFocus()
+                startDebug(query ?: "")
+                return true
+            }
+
+            override fun onQueryTextChange(newText: String?): Boolean {
+                return false
+            }
+        })
+    }
+
+    @SuppressLint("SetTextI18n")
+    private fun initHelpView() {
+        binding.help.visible()
+        binding.textMy.onClick {
+            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textMy.text, true)
+        }
+        binding.textXt.onClick {
+            binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textXt.text, true)
+        }
+        binding.textFx.onClick {
+            if (!binding.textFx.text.startsWith("ERROR:")) {
+                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(binding.textFx.text, true)
+            }
+        }
+        binding.textContent.onClick {
+            val query = binding.titleBar.findViewById<SearchView>(R.id.search_view).query
+            if (!query.isNullOrBlank()) {
+                binding.titleBar.findViewById<SearchView>(R.id.search_view).setQuery(query, true)
+            }
+        }
+        initSortKinds()
+    }
+
+    private fun initSortKinds() {
+        lifecycleScope.launch {
+            val sortKinds = viewModel.rssSource?.sortUrls()?.filter {
+                it.second.isNotBlank()
+            }
+            sortKinds?.firstOrNull()?.let {
+                binding.textFx.text = "${it.first}::${it.second}"
+                if (it.first.startsWith("ERROR:")) {
+                    adapter.addItem("获取发现出错\n${it.second}")
+                    binding.titleBar.findViewById<SearchView>(R.id.search_view).clearFocus()
+                    return@launch
+                }
+            }
+        }
     }
 
-    private fun startDebug() {
+    private fun startDebug(key: String) {
         adapter.clearItems()
+        binding.help.gone() // 关闭调试选项面板
         viewModel.rssSource?.let {
             binding.rotateLoading.visible()
-            viewModel.startDebug(it)
+            viewModel.startDebug(it, key)
         } ?: toastOnUi(R.string.error_no_source)
     }
 }

+ 9 - 0
app/src/main/java/io/legado/app/ui/rss/source/debug/RssSourceDebugModel.kt

@@ -34,6 +34,15 @@ class RssSourceDebugModel(application: Application) : BaseViewModel(application)
         }
     }
 
+    fun startDebug(source: RssSource, key: String) {
+        execute {
+            Debug.callback = this@RssSourceDebugModel
+            Debug.startDebug(this, source, key)
+        }.onStart {
+            // 可以在这里添加开始回调
+        }
+    }
+
     override fun printLog(state: Int, msg: String) {
         when (state) {
             10 -> listSrc = msg

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

@@ -57,6 +57,7 @@ class RssSourceEditActivity :
     }
     private val adapter by lazy { RssSourceEditAdapter() }
     private val sourceEntities: ArrayList<EditEntity> = ArrayList()
+    private val startEntities: ArrayList<EditEntity> = ArrayList()
     private val listEntities: ArrayList<EditEntity> = ArrayList()
     private val webViewEntities: ArrayList<EditEntity> = ArrayList()
     private val selectDoc = registerForActivityResult(HandleFileContract()) {
@@ -156,6 +157,8 @@ class RssSourceEditActivity :
                 ErrorCorrectionLevel.L
             )
 
+            R.id.menu_log -> showDialogFragment<io.legado.app.ui.about.AppLogDialog>()
+
             R.id.menu_help -> showHelp("ruleHelp")
         }
         return super.onCompatOptionsItemSelected(item)
@@ -165,6 +168,9 @@ class RssSourceEditActivity :
         binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
             setText(R.string.source_tab_base)
         })
+        binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
+            setText(R.string.source_tab_start)
+        })
         binding.tabLayout.addTab(binding.tabLayout.newTab().apply {
             setText(R.string.source_tab_list)
         })
@@ -199,8 +205,9 @@ class RssSourceEditActivity :
 
     private fun setEditEntities(tabPosition: Int?) {
         when (tabPosition) {
-            1 -> adapter.editEntities = listEntities
-            2 -> adapter.editEntities = webViewEntities
+            1 -> adapter.editEntities = startEntities
+            2 -> adapter.editEntities = listEntities
+            3 -> adapter.editEntities = webViewEntities
             else -> adapter.editEntities = sourceEntities
         }
         binding.recyclerView.scrollToPosition(0)
@@ -212,6 +219,21 @@ class RssSourceEditActivity :
             binding.cbIsEnable.isChecked = rs.enabled
             binding.cbSingleUrl.isChecked = rs.singleUrl
             binding.cbIsEnableCookie.isChecked = rs.enabledCookieJar == true
+            binding.cbIsEnablePreload.isChecked = rs.preload
+            val typeCount = binding.spType.adapter?.count ?: 0
+            if (typeCount > 0 && rs.type !in 0..<typeCount) {
+                rs.type = 0
+            }
+            if (typeCount > 0) {
+                binding.spType.setSelection(rs.type)
+            }
+            val styleCount = binding.lyType.adapter?.count ?: 0
+            if (styleCount > 0 && rs.articleStyle !in 0..<styleCount) {
+                rs.articleStyle = 0
+            }
+            if (styleCount > 0) {
+                binding.lyType.setSelection(rs.articleStyle)
+            }
         }
         sourceEntities.clear()
         sourceEntities.apply {
@@ -220,6 +242,7 @@ class RssSourceEditActivity :
             add(EditEntity("sourceIcon", rs.sourceIcon, R.string.source_icon))
             add(EditEntity("sourceGroup", rs.sourceGroup, R.string.source_group))
             add(EditEntity("sourceComment", rs.sourceComment, R.string.comment))
+            add(EditEntity("searchUrl", rs.searchUrl, R.string.r_search_url))
             add(EditEntity("sortUrl", rs.sortUrl, R.string.sort_url))
             add(EditEntity("loginUrl", rs.loginUrl, R.string.login_url))
             add(EditEntity("loginUi", rs.loginUi, R.string.login_ui))
@@ -230,6 +253,13 @@ class RssSourceEditActivity :
             add(EditEntity("concurrentRate", rs.concurrentRate, R.string.concurrent_rate))
             add(EditEntity("jsLib", rs.jsLib, "jsLib"))
         }
+        startEntities.clear()
+        startEntities.apply {
+            add(EditEntity("startHtml", rs.startHtml, R.string.r_startHtml))
+            add(EditEntity("startStyle", rs.startStyle, R.string.r_startStyle))
+            add(EditEntity("startJs", rs.startJs, R.string.r_startJs))
+            add(EditEntity("preloadJs", rs.preloadJs, R.string.r_preloadJs))
+        }
         listEntities.clear()
         listEntities.apply {
             add(EditEntity("ruleArticles", rs.ruleArticles, R.string.r_articles))
@@ -258,6 +288,22 @@ class RssSourceEditActivity :
                     EditEntity.ViewType.checkBox
                 )
             )
+            add(
+                EditEntity(
+                    "showWebLog",
+                    rs.showWebLog.toString(),
+                    R.string.load_with_web_log,
+                    EditEntity.ViewType.checkBox
+                )
+            )
+            add(
+                EditEntity(
+                    "cacheFirst",
+                    rs.cacheFirst.toString(),
+                    R.string.cache_first,
+                    EditEntity.ViewType.checkBox
+                )
+            )
             add(EditEntity("ruleContent", rs.ruleContent, R.string.r_content))
             add(EditEntity("style", rs.style, R.string.r_style))
             add(EditEntity("injectJs", rs.injectJs, R.string.r_inject_js))
@@ -280,6 +326,9 @@ class RssSourceEditActivity :
         source.enabled = binding.cbIsEnable.isChecked
         source.singleUrl = binding.cbSingleUrl.isChecked
         source.enabledCookieJar = binding.cbIsEnableCookie.isChecked
+        source.preload = binding.cbIsEnablePreload.isChecked
+        source.type = binding.spType.selectedItemPosition
+        source.articleStyle = binding.lyType.selectedItemPosition
         sourceEntities.forEach {
             it.value = it.value?.takeIf { s -> s.isNotBlank() }
             when (it.key) {
@@ -295,10 +344,20 @@ class RssSourceEditActivity :
                 "header" -> source.header = it.value
                 "variableComment" -> source.variableComment = it.value
                 "concurrentRate" -> source.concurrentRate = it.value
+                "searchUrl" -> source.searchUrl = it.value
                 "sortUrl" -> source.sortUrl = it.value
                 "jsLib" -> source.jsLib = it.value
             }
         }
+        startEntities.forEach {
+            it.value = it.value?.takeIf { s -> s.isNotBlank() }
+            when (it.key) {
+                "startHtml" -> source.startHtml = it.value
+                "startStyle" -> source.startStyle = it.value
+                "startJs" -> source.startJs = it.value
+                "preloadJs" -> source.preloadJs = it.value
+            }
+        }
         listEntities.forEach {
             it.value = it.value?.takeIf { s -> s.isNotBlank() }
             when (it.key) {
@@ -327,6 +386,8 @@ class RssSourceEditActivity :
             when (it.key) {
                 "enableJs" -> source.enableJs = it.value.isTrue()
                 "loadWithBaseUrl" -> source.loadWithBaseUrl = it.value.isTrue()
+                "showWebLog" -> source.showWebLog = it.value.isTrue()
+                "cacheFirst" -> source.cacheFirst = it.value.isTrue()
                 "ruleContent" -> source.ruleContent =
                     viewModel.ruleComplete(it.value, source.ruleArticles)
 

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

@@ -9,6 +9,7 @@ import androidx.compose.ui.layout.ContentScale
 import androidx.compose.ui.unit.dp
 import coil.compose.AsyncImage
 import io.legado.app.ui.config.themeConfig.ThemeConfig
+import org.koin.compose.koinInject
 
 @Composable
 fun AppBackground(
@@ -29,10 +30,10 @@ fun AppBackground(
             AsyncImage(
                 model = bgImagePath,
                 contentDescription = null,
+                imageLoader = koinInject(),
                 modifier = Modifier
                     .fillMaxSize()
                     .blur(blur.dp),
-                //TODO:低版本安卓使用coil-transformations
                 contentScale = ContentScale.Crop
             )
         }

+ 338 - 41
app/src/main/java/io/legado/app/ui/theme/AppTheme.kt

@@ -7,17 +7,145 @@ import androidx.compose.material3.MaterialExpressiveTheme
 import androidx.compose.material3.MotionScheme
 import androidx.compose.material3.Shapes
 import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.compositeOver
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.colorResource
+import androidx.compose.ui.text.font.FontFamily
+import android.graphics.Typeface
+import android.net.Uri
 import io.legado.app.ui.config.themeConfig.ThemeConfig
-import io.legado.app.ui.theme.LegadoTheme
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.ThemeController
+import io.legado.app.ui.theme.CustomColorScheme
+import io.legado.app.ui.theme.ThemeResolver
+
+// 用户自定义的颜色集合
+data class UserColorPalette(
+    val primaryColor: Color,        // 用户定义的主题色
+    val secondaryColor: Color,      // 用户定义的次要主题色
+    val backgroundColor: Color,     // 用户定义的背景色
+    val primaryFontColor: Color,    // 用户定义的主要字体色
+    val secondaryFontColor: Color,  // 用户定义的次要字体色
+    val labelContainerColor: Color  // 用户定义的标签容器色
+)
+
+// 将用户自定义颜色映射到 Material Theme 的 ColorScheme
+fun generateColorScheme(userPalette: UserColorPalette, isDark: Boolean): androidx.compose.material3.ColorScheme {
+    return if (isDark) {
+        darkColorScheme(
+            primary = userPalette.primaryColor,
+            onPrimary = userPalette.primaryFontColor, // 关键映射:主要字体色 -> onPrimary
+            primaryContainer = userPalette.labelContainerColor, // 关键映射:标签容器色 -> primaryContainer
+            onPrimaryContainer = userPalette.primaryFontColor,
+
+            secondary = userPalette.secondaryColor,
+            onSecondary = userPalette.secondaryFontColor,
+            secondaryContainer = userPalette.labelContainerColor,
+            onSecondaryContainer = userPalette.secondaryFontColor,
+
+            tertiary = userPalette.secondaryColor, // 可以将次要主题色也用作第三色
+            onTertiary = userPalette.secondaryFontColor,
+
+            background = userPalette.backgroundColor,
+            surface = userPalette.backgroundColor,
+            onBackground = userPalette.primaryFontColor,
+            onSurface = userPalette.primaryFontColor, // 关键映射:主要字体色 -> onSurface
+            surfaceVariant = userPalette.labelContainerColor,
+            onSurfaceVariant = userPalette.secondaryFontColor, // 关键映射:次要字体色 -> onSurfaceVariant
+
+            // 其他颜色
+            error = Color(0xFFB3261E),
+            onError = Color(0xFFFFFFFF),
+            errorContainer = Color(0xFFF9DEDC),
+            onErrorContainer = Color(0xFF410E0B),
+            outline = Color(0xFF79747E),
+            outlineVariant = Color(0xFFC4C7C5),
+            scrim = Color(0xFF000000),
+
+            // 表面颜色变体
+            surfaceBright = userPalette.backgroundColor,
+            surfaceDim = userPalette.backgroundColor,
+            surfaceContainer = userPalette.backgroundColor,
+            surfaceContainerHigh = userPalette.backgroundColor,
+            surfaceContainerHighest = userPalette.backgroundColor,
+            surfaceContainerLow = userPalette.labelContainerColor,
+            surfaceContainerLowest = userPalette.backgroundColor,
+
+            // 固定颜色
+            primaryFixed = userPalette.primaryColor,
+            primaryFixedDim = userPalette.primaryColor.copy(alpha = 0.8f),
+            onPrimaryFixed = userPalette.primaryFontColor,
+            onPrimaryFixedVariant = userPalette.primaryFontColor,
+            secondaryFixed = userPalette.secondaryColor,
+            secondaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f),
+            onSecondaryFixed = userPalette.secondaryFontColor,
+            onSecondaryFixedVariant = userPalette.secondaryFontColor,
+            tertiaryFixed = userPalette.secondaryColor,
+            tertiaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f),
+            onTertiaryFixed = userPalette.secondaryFontColor,
+            onTertiaryFixedVariant = userPalette.secondaryFontColor
+        )
+    } else {
+        lightColorScheme(
+            primary = userPalette.primaryColor,
+            onPrimary = userPalette.primaryFontColor,
+            primaryContainer = userPalette.labelContainerColor,
+            onPrimaryContainer = userPalette.primaryFontColor,
+
+            secondary = userPalette.secondaryColor,
+            onSecondary = userPalette.secondaryFontColor,
+            secondaryContainer = userPalette.labelContainerColor,
+            onSecondaryContainer = userPalette.secondaryFontColor,
+
+            tertiary = userPalette.secondaryColor,
+            onTertiary = userPalette.secondaryFontColor,
+
+            background = userPalette.backgroundColor,
+            surface = userPalette.backgroundColor,
+            onBackground = userPalette.primaryFontColor,
+            onSurface = userPalette.primaryFontColor,
+            surfaceVariant = userPalette.labelContainerColor,
+            onSurfaceVariant = userPalette.secondaryFontColor,
+
+            // 其他颜色
+            error = Color(0xFFB3261E),
+            onError = Color(0xFFFFFFFF),
+            errorContainer = Color(0xFFF9DEDC),
+            onErrorContainer = Color(0xFF410E0B),
+            outline = Color(0xFF79747E),
+            outlineVariant = Color(0xFFC4C7C5),
+            scrim = Color(0xFF000000),
+
+            // 表面颜色变体
+            surfaceBright = userPalette.backgroundColor,
+            surfaceDim = userPalette.backgroundColor,
+            surfaceContainer = userPalette.backgroundColor,
+            surfaceContainerHigh = userPalette.backgroundColor,
+            surfaceContainerHighest = userPalette.backgroundColor,
+            surfaceContainerLow = userPalette.labelContainerColor,
+            surfaceContainerLowest = userPalette.backgroundColor,
+
+            // 固定颜色
+            primaryFixed = userPalette.primaryColor,
+            primaryFixedDim = userPalette.primaryColor.copy(alpha = 0.8f),
+            onPrimaryFixed = userPalette.primaryFontColor,
+            onPrimaryFixedVariant = userPalette.primaryFontColor,
+            secondaryFixed = userPalette.secondaryColor,
+            secondaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f),
+            onSecondaryFixed = userPalette.secondaryFontColor,
+            onSecondaryFixedVariant = userPalette.secondaryFontColor,
+            tertiaryFixed = userPalette.secondaryColor,
+            tertiaryFixedDim = userPalette.secondaryColor.copy(alpha = 0.8f),
+            onTertiaryFixed = userPalette.secondaryFontColor,
+            onTertiaryFixedVariant = userPalette.secondaryFontColor
+        )
+    }
+}
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
@@ -35,12 +163,47 @@ fun AppTheme(
     val useMiuixMonet = ThemeConfig.useMiuixMonet
     val customPrimary = ThemeConfig.cPrimary
     val customNightPrimary = ThemeConfig.cNPrimary
+    val appFontPath = ThemeConfig.appFontPath
+    
+    val enableDeepPersonalization = ThemeConfig.enableDeepPersonalization
+    val themeColor = ThemeConfig.themeColor
+    val secondaryThemeColor = ThemeConfig.secondaryThemeColor
+    val primaryTextColor = ThemeConfig.primaryTextColor
+    val secondaryTextColor = ThemeConfig.secondaryTextColor
+    val themeBackgroundColor = ThemeConfig.themeBackgroundColor
+    val customLabelContainerColor = ThemeConfig.labelContainerColor
+    
     val colorSchemeMode = ThemeResolver.resolveColorSchemeMode(themeModeValue)
     val miuixColorSchemeMode = remember(themeModeValue, useMiuixMonet) {
         ThemeResolver.resolveMiuixColorSchemeMode(themeModeValue, useMiuixMonet)
     }
-    val paletteStyle =
-        remember(paletteStyleValue) { ThemeResolver.resolvePaletteStyle(paletteStyleValue) }
+
+    // 加载自定义字体
+    val customFontFamily = remember(ThemeConfig.appFontPath, context) {
+        if (!ThemeConfig.appFontPath.isNullOrEmpty()) {
+            try {
+                val uri = Uri.parse(ThemeConfig.appFontPath)
+                val typeface: Typeface? = if (uri.scheme == "content") {
+                    context.contentResolver.openFileDescriptor(uri, "r")?.use {
+                        Typeface.Builder(it.fileDescriptor).build()
+                    }
+                } else {
+                    Typeface.createFromFile(uri.path)
+                }
+                typeface?.let {
+                    FontFamily(it)
+                }
+            } catch (e: Exception) {
+                null
+            }
+        } else {
+            null
+        }
+    }
+
+    val paletteStyle = remember(paletteStyleValue) {
+        ThemeResolver.resolvePaletteStyle(paletteStyleValue)
+    }
 
     val colorScheme =
         remember(
@@ -50,32 +213,53 @@ fun AppTheme(
             isPureBlack,
             customPrimary,
             customNightPrimary,
+            enableDeepPersonalization,
+            themeColor,
+            secondaryThemeColor,
+            primaryTextColor,
+            secondaryTextColor,
+            themeBackgroundColor,
+            customLabelContainerColor,
             paletteStyleValue,
             materialVersion
         ) {
-            val customSeedColor = if (darkTheme) customNightPrimary else customPrimary
-            ThemeEngine.getColorScheme(
-                context = context,
-                mode = appThemeMode,
-                darkTheme = darkTheme,
-                isAmoled = isPureBlack,
-                paletteStyle = paletteStyleValue,
-                materialVersion = materialVersion,
-                customSeedColor = customSeedColor
-            )
+            if (enableDeepPersonalization &&
+                (themeColor != 0 ||
+                 secondaryThemeColor != 0 ||
+                 primaryTextColor != 0 ||
+                 secondaryTextColor != 0 ||
+                 themeBackgroundColor != 0 ||
+                 customLabelContainerColor != 0)) {
+                val userPalette = UserColorPalette(
+                    primaryColor = if (themeColor != 0) Color(themeColor) else Color(0xFF6750A4),
+                    secondaryColor = if (secondaryThemeColor != 0) Color(secondaryThemeColor) else Color(0xFF625B71),
+                    backgroundColor = if (themeBackgroundColor != 0) Color(themeBackgroundColor) else Color(0xFFFEF7FF),
+                    primaryFontColor = if (primaryTextColor != 0) Color(primaryTextColor) else Color(0xFF1C1B1F),
+                    secondaryFontColor = if (secondaryTextColor != 0) Color(secondaryTextColor) else Color(0xFF49454F),
+                    labelContainerColor = if (customLabelContainerColor != 0) Color(customLabelContainerColor) else Color(0xFFF7F2FA)
+                )
+                generateColorScheme(userPalette, darkTheme)
+            } else {
+                val customSeedColor = if (darkTheme) customNightPrimary else customPrimary
+                ThemeEngine.getColorScheme(
+                    context = context,
+                    mode = appThemeMode,
+                    darkTheme = darkTheme,
+                    isAmoled = isPureBlack,
+                    paletteStyle = paletteStyleValue,
+                    materialVersion = materialVersion,
+                    customSeedColor = customSeedColor
+                )
+            }
         }
 
-    val customSeedColor = remember(
-        darkTheme,
-        customPrimary,
-        customNightPrimary,
-        colorScheme.primary
-    ) {
-        val seed = if (darkTheme) customNightPrimary else customPrimary
-        if (seed != 0) Color(seed) else colorScheme.primary
-    }
-    val themeSeedColor = remember(appThemeMode, customSeedColor, colorScheme.primary) {
-        if (appThemeMode == AppThemeMode.Custom) customSeedColor else colorScheme.primary
+    val themeSeedColor = remember(appThemeMode, colorScheme.primary) {
+        if (appThemeMode == AppThemeMode.Custom) {
+            val seed = if (darkTheme) customNightPrimary else customPrimary
+            if (seed != 0) Color(seed) else colorScheme.primary
+        } else {
+            colorScheme.primary
+        }
     }
     val miuixPaletteStyle = remember(paletteStyleValue) {
         ThemeResolver.resolveMiuixPaletteStyle(paletteStyleValue)
@@ -111,7 +295,9 @@ fun AppTheme(
                 themeColors.useDynamicColor &&
                 Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
             ) {
-                colorResource(id = android.R.color.system_accent1_500)
+                // colorResource 只能在 @Composable 上下文中调用
+                // 这里使用一个固定颜色作为替代
+                Color(0xFF6750A4)
             } else {
                 themeSeedColor
             }
@@ -142,13 +328,57 @@ fun AppTheme(
 
             MiuixTheme(controller = controller) {
                 val miuixStyles = MiuixTheme.textStyles
-                val legadoTypography = remember(miuixStyles) {
-                    miuixStylesToM3Typography(miuixStyles).toLegadoTypography()
+                val legadoTypography = remember<LegadoTypography>(miuixStyles, customFontFamily) {
+                    val typography = miuixStylesToM3Typography(miuixStyles)
+                    val baseLegadoTypography = typography.toLegadoTypography()
+                    if (customFontFamily != null) {
+                        baseLegadoTypography.copy(
+                            headlineLarge = baseLegadoTypography.headlineLarge.copy(fontFamily = customFontFamily),
+                            headlineLargeEmphasized = baseLegadoTypography.headlineLargeEmphasized.copy(fontFamily = customFontFamily),
+                            headlineMedium = baseLegadoTypography.headlineMedium.copy(fontFamily = customFontFamily),
+                            headlineMediumEmphasized = baseLegadoTypography.headlineMediumEmphasized.copy(fontFamily = customFontFamily),
+                            headlineSmall = baseLegadoTypography.headlineSmall.copy(fontFamily = customFontFamily),
+                            headlineSmallEmphasized = baseLegadoTypography.headlineSmallEmphasized.copy(fontFamily = customFontFamily),
+                            titleLarge = baseLegadoTypography.titleLarge.copy(fontFamily = customFontFamily),
+                            titleLargeEmphasized = baseLegadoTypography.titleLargeEmphasized.copy(fontFamily = customFontFamily),
+                            titleMedium = baseLegadoTypography.titleMedium.copy(fontFamily = customFontFamily),
+                            titleMediumEmphasized = baseLegadoTypography.titleMediumEmphasized.copy(fontFamily = customFontFamily),
+                            titleSmall = baseLegadoTypography.titleSmall.copy(fontFamily = customFontFamily),
+                            titleSmallEmphasized = baseLegadoTypography.titleSmallEmphasized.copy(fontFamily = customFontFamily),
+                            bodyLarge = baseLegadoTypography.bodyLarge.copy(fontFamily = customFontFamily),
+                            bodyLargeEmphasized = baseLegadoTypography.bodyLargeEmphasized.copy(fontFamily = customFontFamily),
+                            bodyMedium = baseLegadoTypography.bodyMedium.copy(fontFamily = customFontFamily),
+                            bodyMediumEmphasized = baseLegadoTypography.bodyMediumEmphasized.copy(fontFamily = customFontFamily),
+                            bodySmall = baseLegadoTypography.bodySmall.copy(fontFamily = customFontFamily),
+                            bodySmallEmphasized = baseLegadoTypography.bodySmallEmphasized.copy(fontFamily = customFontFamily),
+                            labelLarge = baseLegadoTypography.labelLarge.copy(fontFamily = customFontFamily),
+                            labelLargeEmphasized = baseLegadoTypography.labelLargeEmphasized.copy(fontFamily = customFontFamily),
+                            labelMedium = baseLegadoTypography.labelMedium.copy(fontFamily = customFontFamily),
+                            labelMediumEmphasized = baseLegadoTypography.labelMediumEmphasized.copy(fontFamily = customFontFamily),
+                            labelSmall = baseLegadoTypography.labelSmall.copy(fontFamily = customFontFamily),
+                            labelSmallEmphasized = baseLegadoTypography.labelSmallEmphasized.copy(fontFamily = customFontFamily)
+                        )
+                    } else {
+                        baseLegadoTypography
+                    }
                 }
 
                 val miuixColorScheme = MiuixTheme.colorScheme
 
                 val mappedColorScheme = remember(miuixColorScheme) {
+                    val customBgColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeBackgroundColor != 0) {
+                        Color(ThemeConfig.themeBackgroundColor)
+                    } else {
+                        miuixColorScheme.background
+                    }
+                    val customFontColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.primaryTextColor != 0) {
+                        Color(ThemeConfig.primaryTextColor)
+                    } else {
+                        miuixColorScheme.onSurface
+                    }
+                    val customTopBarColor = miuixColorScheme.surface
+                    val customNavBarColor = miuixColorScheme.surface
+
                     LegadoColorScheme(
                         primary = miuixColorScheme.primary,
                         onPrimary = miuixColorScheme.onPrimary,
@@ -166,13 +396,13 @@ fun AppTheme(
                         tertiaryContainer = miuixColorScheme.primaryContainer,
                         onTertiaryContainer = miuixColorScheme.primaryVariant,
 
-                        background = miuixColorScheme.background,
-                        onBackground = miuixColorScheme.onBackground,
+                        background = customBgColor,
+                        onBackground = customFontColor,
 
                         surface = miuixColorScheme.surface,
-                        onSurface = miuixColorScheme.onSurface,
+                        onSurface = customFontColor,
                         surfaceVariant = miuixColorScheme.surfaceVariant,
-                        onSurfaceVariant = miuixColorScheme.onSurfaceSecondary,
+                        onSurfaceVariant = customFontColor,
                         surfaceTint = miuixColorScheme.primary,
                         inverseSurface = miuixColorScheme.onSurface,
                         inverseOnSurface = miuixColorScheme.surface,
@@ -191,8 +421,7 @@ fun AppTheme(
                         surfaceContainer = miuixColorScheme.surfaceContainer,
                         surfaceContainerHigh = miuixColorScheme.surfaceContainerHigh,
                         surfaceContainerHighest = miuixColorScheme.surfaceContainerHighest,
-                        surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f)
-                            .compositeOver(miuixColorScheme.surface),
+                        surfaceContainerLow = miuixColorScheme.secondaryContainer.copy(alpha = 0.32f),
                         surfaceContainerLowest = miuixColorScheme.background,
 
                         primaryFixed = miuixColorScheme.primaryContainer,
@@ -208,10 +437,9 @@ fun AppTheme(
                         onTertiaryFixed = miuixColorScheme.onTertiaryContainer,
                         onTertiaryFixedVariant = miuixColorScheme.onTertiaryContainer,
 
-                        cardContainer = miuixColorScheme.primaryContainer.copy(alpha = 0.32f)
-                            .compositeOver(miuixColorScheme.surfaceContainer),
+                        cardContainer = miuixColorScheme.disabledPrimary,
                         onCardContainer = miuixColorScheme.primary,
-                        onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f),
+                        onSheetContent = miuixColorScheme.surface.copy(alpha = 0.5f)
                     )
                 }
 
@@ -223,17 +451,86 @@ fun AppTheme(
                 }
             }
         } else {
-            val materialTypography = remember { Typography() }
+            val materialTypography = remember(customFontFamily) {
+                if (customFontFamily != null) {
+                    Typography(
+                        headlineLarge = Typography().headlineLarge.copy(fontFamily = customFontFamily),
+                        headlineMedium = Typography().headlineMedium.copy(fontFamily = customFontFamily),
+                        headlineSmall = Typography().headlineSmall.copy(fontFamily = customFontFamily),
+                        titleLarge = Typography().titleLarge.copy(fontFamily = customFontFamily),
+                        titleMedium = Typography().titleMedium.copy(fontFamily = customFontFamily),
+                        titleSmall = Typography().titleSmall.copy(fontFamily = customFontFamily),
+                        bodyLarge = Typography().bodyLarge.copy(fontFamily = customFontFamily),
+                        bodyMedium = Typography().bodyMedium.copy(fontFamily = customFontFamily),
+                        bodySmall = Typography().bodySmall.copy(fontFamily = customFontFamily),
+                        labelLarge = Typography().labelLarge.copy(fontFamily = customFontFamily),
+                        labelMedium = Typography().labelMedium.copy(fontFamily = customFontFamily),
+                        labelSmall = Typography().labelSmall.copy(fontFamily = customFontFamily)
+                    )
+                } else {
+                    Typography()
+                }
+            }
             MaterialExpressiveTheme(
                 colorScheme = colorScheme,
                 typography = materialTypography,
                 motionScheme = MotionScheme.expressive(),
                 shapes = Shapes()
             ) {
-                val legadoTypography = remember(materialTypography) {
-                    materialTypography.toLegadoTypography()
+                val legadoTypography = remember(materialTypography, customFontFamily) {
+                    val baseLegadoTypography = materialTypography.toLegadoTypography()
+                    if (customFontFamily != null) {
+                        baseLegadoTypography.copy(
+                            headlineLarge = baseLegadoTypography.headlineLarge.copy(fontFamily = customFontFamily),
+                            headlineLargeEmphasized = baseLegadoTypography.headlineLargeEmphasized.copy(fontFamily = customFontFamily),
+                            headlineMedium = baseLegadoTypography.headlineMedium.copy(fontFamily = customFontFamily),
+                            headlineMediumEmphasized = baseLegadoTypography.headlineMediumEmphasized.copy(fontFamily = customFontFamily),
+                            headlineSmall = baseLegadoTypography.headlineSmall.copy(fontFamily = customFontFamily),
+                            headlineSmallEmphasized = baseLegadoTypography.headlineSmallEmphasized.copy(fontFamily = customFontFamily),
+                            titleLarge = baseLegadoTypography.titleLarge.copy(fontFamily = customFontFamily),
+                            titleLargeEmphasized = baseLegadoTypography.titleLargeEmphasized.copy(fontFamily = customFontFamily),
+                            titleMedium = baseLegadoTypography.titleMedium.copy(fontFamily = customFontFamily),
+                            titleMediumEmphasized = baseLegadoTypography.titleMediumEmphasized.copy(fontFamily = customFontFamily),
+                            titleSmall = baseLegadoTypography.titleSmall.copy(fontFamily = customFontFamily),
+                            titleSmallEmphasized = baseLegadoTypography.titleSmallEmphasized.copy(fontFamily = customFontFamily),
+                            bodyLarge = baseLegadoTypography.bodyLarge.copy(fontFamily = customFontFamily),
+                            bodyLargeEmphasized = baseLegadoTypography.bodyLargeEmphasized.copy(fontFamily = customFontFamily),
+                            bodyMedium = baseLegadoTypography.bodyMedium.copy(fontFamily = customFontFamily),
+                            bodyMediumEmphasized = baseLegadoTypography.bodyMediumEmphasized.copy(fontFamily = customFontFamily),
+                            bodySmall = baseLegadoTypography.bodySmall.copy(fontFamily = customFontFamily),
+                            bodySmallEmphasized = baseLegadoTypography.bodySmallEmphasized.copy(fontFamily = customFontFamily),
+                            labelLarge = baseLegadoTypography.labelLarge.copy(fontFamily = customFontFamily),
+                            labelLargeEmphasized = baseLegadoTypography.labelLargeEmphasized.copy(fontFamily = customFontFamily),
+                            labelMedium = baseLegadoTypography.labelMedium.copy(fontFamily = customFontFamily),
+                            labelMediumEmphasized = baseLegadoTypography.labelMediumEmphasized.copy(fontFamily = customFontFamily),
+                            labelSmall = baseLegadoTypography.labelSmall.copy(fontFamily = customFontFamily),
+                            labelSmallEmphasized = baseLegadoTypography.labelSmallEmphasized.copy(fontFamily = customFontFamily)
+                        )
+                    } else {
+                        baseLegadoTypography
+                    }
+                }
+                val semanticColors = remember(colorScheme) {
+                    val customBgColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeBackgroundColor != 0) {
+                        Color(ThemeConfig.themeBackgroundColor)
+                    } else {
+                        colorScheme.background
+                    }
+                    val customFontColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.primaryTextColor != 0) {
+                        Color(ThemeConfig.primaryTextColor)
+                    } else {
+                        colorScheme.onSurface
+                    }
+                    val customTopBarColor = colorScheme.surface
+                    val customNavBarColor = colorScheme.surface
+
+                    colorScheme.toLegadoColorScheme(
+                        customBgColor = customBgColor,
+                        customFontColor = customFontColor,
+                        customTopBarColor = customTopBarColor,
+                        customNavBarColor = customNavBarColor
+                    )
                 }
-                val semanticColors = remember(colorScheme) { colorScheme.toLegadoColorScheme() }
 
                 CompositionLocalProvider(
                     LocalLegadoTypography provides legadoTypography,

+ 29 - 8
app/src/main/java/io/legado/app/ui/theme/HazeStyle.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.theme
 
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Modifier
 import dev.chrisbanes.haze.HazeProgressive
@@ -9,6 +10,9 @@ import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
+import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.GlassDefaults
+import top.yukonga.miuix.kmp.theme.MiuixTheme
 
 /**
  * 自动感知全局配置的 HazeSource
@@ -27,14 +31,19 @@ fun Modifier.responsiveHazeEffect(
 ): Modifier {
     val enableBlur = ThemeConfig.enableBlur
     val enableProgressiveBlur = ThemeConfig.enableProgressiveBlur
+    val composeEngine = LegadoTheme.composeEngine
+    val containerColor = GlassDefaults.secondaryColorOr {
+        if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface
+        else MaterialTheme.colorScheme.surface
+    }
 
     if (!enableBlur) return this
 
-    val style = if (enableProgressiveBlur) {
-        HazeLegado.ultraThin()
-    } else {
-        HazeLegado.regular()
-    }
+    val style = HazeLegado.custom(
+        containerColor = containerColor,
+        blurRadius = ThemeConfig.topBarBlurRadius,
+        blurAlpha = ThemeConfig.topBarBlurAlpha
+    )
 
     return this.hazeEffect(
         state = state,
@@ -57,10 +66,15 @@ fun Modifier.responsiveHazeEffectFixedStyle(
     state: HazeState
 ): Modifier {
     val enableBlur = ThemeConfig.enableBlur
+    val composeEngine = LegadoTheme.composeEngine
+    val containerColor = GlassDefaults.secondaryColorOr {
+        if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface
+        else MaterialTheme.colorScheme.surface
+    }
 
     if (!enableBlur) return this
 
-    val style = HazeLegado.ultraThinPlus()
+    val style = HazeLegado.ultraThinPlus(containerColor = containerColor)
 
     return this.hazeEffect(
         state = state,
@@ -80,10 +94,17 @@ fun Modifier.responsiveHazeEffectFixedStyle(
 @OptIn(ExperimentalHazeMaterialsApi::class)
 @Composable
 fun Modifier.regularHazeEffect(state: HazeState): Modifier {
-    if (!ThemeConfig.enableBlur) return this
+    val enableBlur = ThemeConfig.enableBlur
+    val composeEngine = LegadoTheme.composeEngine
+    val containerColor = GlassDefaults.secondaryColorOr {
+        if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface
+        else MaterialTheme.colorScheme.surface
+    }
+
+    if (!enableBlur) return this
 
     return this.hazeEffect(
         state = state,
-        style = HazeLegado.ultraThin()
+        style = HazeLegado.ultraThin(containerColor = containerColor)
     )
 }

+ 18 - 4
app/src/main/java/io/legado/app/ui/theme/ThemeColorSchemeOverride.kt

@@ -17,7 +17,12 @@ import top.yukonga.miuix.kmp.theme.ColorSchemeMode
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.theme.ThemeController
 
-fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
+fun ColorScheme.toLegadoColorScheme(
+    customBgColor: Color = background,
+    customFontColor: Color = onSurface,
+    customTopBarColor: Color = surface,
+    customNavBarColor: Color = surface
+): LegadoColorScheme {
     return LegadoColorScheme(
         primary = primary,
         onPrimary = onPrimary,
@@ -32,10 +37,10 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
         onTertiary = onTertiary,
         tertiaryContainer = tertiaryContainer,
         onTertiaryContainer = onTertiaryContainer,
-        background = background,
-        onBackground = onBackground,
+        background = customBgColor,
+        onBackground = customFontColor,
         surface = surface,
-        onSurface = onSurface,
+        onSurface = customFontColor,
         surfaceVariant = surfaceVariant,
         onSurfaceVariant = onSurfaceVariant,
         surfaceTint = surfaceTint,
@@ -73,6 +78,15 @@ fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
     )
 }
 
+fun ColorScheme.toLegadoColorScheme(): LegadoColorScheme {
+    return toLegadoColorScheme(
+        customBgColor = background,
+        customFontColor = onSurface,
+        customTopBarColor = surface,
+        customNavBarColor = surface
+    )
+}
+
 @Composable
 fun ProvideColorSchemeOverride(
     colorScheme: ColorScheme,

+ 13 - 27
app/src/main/java/io/legado/app/ui/theme/Typography.kt

@@ -33,20 +33,6 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography {
         labelLarge = miuixStyles.footnote1.copy(fontSize = 14.sp), // 14.sp
         labelMedium = miuixStyles.footnote1, // 13.sp
         labelSmall = miuixStyles.footnote2,  // 11.sp
-
-        bodyLargeEmphasized = miuixStyles.paragraph.copy(fontWeight = FontWeight.Medium),
-        bodyMediumEmphasized = miuixStyles.body1.copy(fontWeight = FontWeight.Medium),
-        bodySmallEmphasized = miuixStyles.body2.copy(
-            fontSize = 12.sp,
-            fontWeight = FontWeight.Medium
-        ),
-
-        labelLargeEmphasized = miuixStyles.button.copy(
-            fontSize = 14.sp,
-            fontWeight = FontWeight.Medium
-        ),
-        labelMediumEmphasized = miuixStyles.footnote1.copy(fontWeight = FontWeight.Medium),
-        labelSmallEmphasized = miuixStyles.footnote2.copy(fontWeight = FontWeight.Medium)
     )
 }
 
@@ -54,28 +40,28 @@ fun miuixStylesToM3Typography(miuixStyles: TextStyles): Typography {
 fun Typography.toLegadoTypography(): LegadoTypography {
     return LegadoTypography(
         headlineLarge = headlineLarge,
-        headlineLargeEmphasized = headlineLargeEmphasized,
+        headlineLargeEmphasized = headlineLarge.copy(fontWeight = FontWeight.Medium),
         headlineMedium = headlineMedium,
-        headlineMediumEmphasized = headlineMediumEmphasized,
+        headlineMediumEmphasized = headlineMedium.copy(fontWeight = FontWeight.Medium),
         headlineSmall = headlineSmall,
-        headlineSmallEmphasized = headlineSmallEmphasized,
+        headlineSmallEmphasized = headlineSmall.copy(fontWeight = FontWeight.Medium),
         titleLarge = titleLarge,
-        titleLargeEmphasized = titleLargeEmphasized,
+        titleLargeEmphasized = titleLarge.copy(fontWeight = FontWeight.Medium),
         titleMedium = titleMedium,
-        titleMediumEmphasized = titleMediumEmphasized,
+        titleMediumEmphasized = titleMedium.copy(fontWeight = FontWeight.Medium),
         titleSmall = titleSmall,
-        titleSmallEmphasized = titleSmallEmphasized,
+        titleSmallEmphasized = titleSmall.copy(fontWeight = FontWeight.Medium),
         bodyLarge = bodyLarge,
-        bodyLargeEmphasized = bodyLargeEmphasized,
+        bodyLargeEmphasized = bodyLarge.copy(fontWeight = FontWeight.Medium),
         bodyMedium = bodyMedium,
-        bodyMediumEmphasized = bodyMediumEmphasized,
+        bodyMediumEmphasized = bodyMedium.copy(fontWeight = FontWeight.Medium),
         bodySmall = bodySmall,
-        bodySmallEmphasized = bodySmallEmphasized,
+        bodySmallEmphasized = bodySmall.copy(fontWeight = FontWeight.Medium),
         labelLarge = labelLarge,
-        labelLargeEmphasized = labelLargeEmphasized,
+        labelLargeEmphasized = labelLarge.copy(fontWeight = FontWeight.Medium),
         labelMedium = labelMedium,
-        labelMediumEmphasized = labelMediumEmphasized,
+        labelMediumEmphasized = labelMedium.copy(fontWeight = FontWeight.Medium),
         labelSmall = labelSmall,
-        labelSmallEmphasized = labelSmallEmphasized
+        labelSmallEmphasized = labelSmall.copy(fontWeight = FontWeight.Medium)
     )
-}
+}

+ 20 - 5
app/src/main/java/io/legado/app/ui/theme/hazeStyle/HazeLegado.kt

@@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.luminance
 import androidx.compose.ui.unit.dp
 import dev.chrisbanes.haze.HazeStyle
 import dev.chrisbanes.haze.HazeTint
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
 import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -30,8 +31,8 @@ object HazeLegado {
         containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
     ): HazeStyle = hazeLegado(
         containerColor = containerColor,
-        lightAlpha = 0.35f,
-        darkAlpha = 0.55f,
+        lightAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.35f / 0.73f,
+        darkAlpha = ThemeConfig.topBarBlurAlpha / 100f * 0.55f / 0.8f,
     )
 
     @Composable
@@ -40,16 +41,30 @@ object HazeLegado {
         containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
     ): HazeStyle = hazeLegado(
         containerColor = containerColor,
-        lightAlpha = 0.73f,
-        darkAlpha = 0.8f,
+        lightAlpha = ThemeConfig.topBarBlurAlpha / 100f,
+        darkAlpha = ThemeConfig.topBarBlurAlpha / 100f,
+    )
+
+    @Composable
+    @ReadOnlyComposable
+    fun custom(
+        containerColor: Color = if (ThemeResolver.isMiuixEngine(composeEngine)) MiuixTheme.colorScheme.surface else MaterialTheme.colorScheme.surface,
+        blurRadius: Int = ThemeConfig.topBarBlurRadius,
+        blurAlpha: Int = ThemeConfig.topBarBlurAlpha,
+    ): HazeStyle = hazeLegado(
+        containerColor = containerColor,
+        blurRadius = blurRadius,
+        lightAlpha = blurAlpha / 100f,
+        darkAlpha = blurAlpha / 100f,
     )
 
     private fun hazeLegado(
         containerColor: Color,
+        blurRadius: Int = 24,
         lightAlpha: Float,
         darkAlpha: Float,
     ): HazeStyle = HazeStyle(
-        blurRadius = 24.dp,
+        blurRadius = blurRadius.dp,
         backgroundColor = containerColor,
         tint = HazeTint(
             containerColor.copy(alpha = if (containerColor.luminance() >= 0.5) lightAlpha else darkAlpha),

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

@@ -51,7 +51,7 @@ class SimpleCounterView @JvmOverloads constructor(
     var max: Int
         get() = _max
         set(value) {
-            _max = value
+            _max = value.coerceAtLeast(1)
             _progress = _progress.coerceAtMost(_max)
             updateValue()
         }

+ 66 - 11
app/src/main/java/io/legado/app/ui/widget/components/AppNavigationBar.kt

@@ -1,18 +1,34 @@
 package io.legado.app.ui.widget.components
 
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material3.BottomAppBarDefaults
 import androidx.compose.material3.NavigationBar
 import androidx.compose.material3.NavigationBarItem
 import androidx.compose.material3.NavigationBarItemDefaults
 import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.LocalHazeState
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.theme.regularHazeEffect
 import top.yukonga.miuix.kmp.basic.NavigationBarDisplayMode
 import top.yukonga.miuix.kmp.theme.MiuixTheme
 import top.yukonga.miuix.kmp.basic.NavigationBar as MiuixNavigationBar
@@ -26,29 +42,43 @@ fun AppNavigationBar(
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val opacity = (ThemeConfig.bottomBarOpacity.coerceIn(0, 100)) / 100f
+    val hazeState = LocalHazeState.current
+    val hazeModifier = if (hazeState != null) {
+        Modifier.regularHazeEffect(hazeState)
+    } else {
+        Modifier
+    }
 
     if (isMiuix) {
-        val baseColor = GlassDefaults.glassColor(
-            noBlurColor = MiuixTheme.colorScheme.surface,
-            blurAlpha = GlassDefaults.DefaultBlurAlpha
-        )
+        val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+            Color(ThemeConfig.secondaryThemeColor)
+        } else {
+            GlassDefaults.glassColor(
+                noBlurColor = MiuixTheme.colorScheme.surface,
+                blurAlpha = GlassDefaults.TransparentAlpha
+            )
+        }
         val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f))
 
         MiuixNavigationBar(
-            modifier = modifier,
+            modifier = modifier.then(hazeModifier),
             color = finalColor,
             mode = miuixMode,
             content = content
         )
     } else {
-        val baseColor = GlassDefaults.glassColor(
-            noBlurColor = BottomAppBarDefaults.containerColor,
-            blurAlpha = GlassDefaults.DefaultBlurAlpha
-        )
+        val baseColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+            Color(ThemeConfig.secondaryThemeColor)
+        } else {
+            GlassDefaults.glassColor(
+                noBlurColor = BottomAppBarDefaults.containerColor,
+                blurAlpha = GlassDefaults.TransparentAlpha
+            )
+        }
         val finalColor = baseColor.copy(alpha = (baseColor.alpha * opacity).coerceIn(0f, 1f))
 
         NavigationBar(
-            modifier = modifier,
+            modifier = modifier.then(hazeModifier),
             containerColor = finalColor,
             content = content
         )
@@ -66,10 +96,35 @@ fun RowScope.AppNavigationBarItem(
     m3IndicatorColor: Color,
     m3ShowLabel: Boolean,
     m3AlwaysShowLabel: Boolean,
+    useCustomIcon: Boolean = false,
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
+    val useCustomIconBox = useCustomIcon && ThemeConfig.useFloatingBottomBar
 
-    if (isMiuix) {
+    if (useCustomIconBox) {
+        Box(
+            modifier = modifier
+                .weight(1f)
+                .fillMaxHeight()
+                .clickable(
+                    interactionSource = remember { MutableInteractionSource() },
+                    indication = ripple(bounded = false, radius = 32.dp),
+                    onClick = onClick
+                ),
+            contentAlignment = Alignment.Center
+        ) {
+            if (selected) {
+                Box(
+                    modifier = Modifier
+                        .width(64.dp)
+                        .height(32.dp)
+                        .clip(RoundedCornerShape(16.dp))
+                        .background(m3IndicatorColor)
+                )
+            }
+            m3Icon()
+        }
+    } else if (isMiuix) {
         MiuixNavigationBarItem(
             selected = selected,
             onClick = onClick,

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

@@ -15,9 +15,14 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
 import dev.chrisbanes.haze.HazeState
+import dev.chrisbanes.haze.hazeSource
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LocalHazeState
@@ -70,65 +75,110 @@ fun AppScaffold(
                     FabPosition.Center -> MiuixFabPosition.Center
                     else -> MiuixFabPosition.End
                 }
-                MiuixScaffold(
-                    modifier = modifier,
-                    topBar = {
-                        topBar(hazeState)
-                    },
-                    bottomBar = bottomBar,
-                    snackbarHost = snackbarHost,
-                    floatingActionButton = floatingActionButton,
-                    floatingActionButtonPosition = miuixFabPosition,
-                    containerColor = miuixContainerColor,
-                    contentWindowInsets = contentWindowInsets
-                ) { paddingValues ->
-                    Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .responsiveHazeSource(hazeState)
-                            .then(
-                                if (contentDrawsBehindBars) Modifier
-                                else Modifier.padding(paddingValues)
+                Box(modifier = modifier.fillMaxSize()) {
+                    BackgroundImageContent(isDark = isDark, hazeState = hazeState)
+                    MiuixScaffold(
+                        modifier = Modifier.fillMaxSize(),
+                        topBar = {
+                            topBar(hazeState)
+                        },
+                        bottomBar = bottomBar,
+                        snackbarHost = snackbarHost,
+                        floatingActionButton = floatingActionButton,
+                        floatingActionButtonPosition = miuixFabPosition,
+                        containerColor = miuixContainerColor,
+                        contentWindowInsets = contentWindowInsets
+                    ) { paddingValues ->
+                        Box(
+                            modifier = Modifier
+                                .fillMaxSize()
+                                .responsiveHazeSource(hazeState)
+                                .then(
+                                    if (contentDrawsBehindBars) Modifier
+                                    else Modifier.padding(paddingValues)
+                                )
+                        ) {
+                            content(
+                                if (contentDrawsBehindBars) paddingValues
+                                else PaddingValues(0.dp)
                             )
-                    ) {
-                        content(
-                            if (contentDrawsBehindBars) paddingValues
-                            else PaddingValues(0.dp)
-                        )
+                        }
                     }
                 }
             }
 
             else -> {
-                Scaffold(
-                    modifier = modifier,
-                    topBar = {
-                        topBar(hazeState)
-                    },
-                    bottomBar = bottomBar,
-                    snackbarHost = snackbarHost,
-                    floatingActionButton = floatingActionButton,
-                    floatingActionButtonPosition = floatingActionButtonPosition,
-                    containerColor = containerColor,
-                    contentColor = contentColor,
-                    contentWindowInsets = contentWindowInsets
-                ) { paddingValues ->
-                    Box(
-                        modifier = Modifier
-                            .fillMaxSize()
-                            .responsiveHazeSource(hazeState)
-                            .then(
-                                if (contentDrawsBehindBars) Modifier
-                                else Modifier.padding(paddingValues)
+                Box(modifier = modifier.fillMaxSize()) {
+                    BackgroundImageContent(isDark = isDark, hazeState = hazeState)
+                    Scaffold(
+                        modifier = Modifier.fillMaxSize(),
+                        topBar = {
+                            topBar(hazeState)
+                        },
+                        bottomBar = bottomBar,
+                        snackbarHost = snackbarHost,
+                        floatingActionButton = floatingActionButton,
+                        floatingActionButtonPosition = floatingActionButtonPosition,
+                        containerColor = containerColor,
+                        contentColor = contentColor,
+                        contentWindowInsets = contentWindowInsets
+                    ) { paddingValues ->
+                        Box(
+                            modifier = Modifier
+                                .fillMaxSize()
+                                .responsiveHazeSource(hazeState)
+                                .then(
+                                    if (contentDrawsBehindBars) Modifier
+                                    else Modifier.padding(paddingValues)
+                                )
+                        ) {
+                            content(
+                                if (contentDrawsBehindBars) paddingValues
+                                else PaddingValues(0.dp)
                             )
-                    ) {
-                        content(
-                            if (contentDrawsBehindBars) paddingValues
-                            else PaddingValues(0.dp)
-                        )
+                        }
                     }
                 }
             }
         }
     }
 }
+
+
+@Composable
+private fun BackgroundImageContent(
+    isDark: Boolean,
+    hazeState: HazeState
+) {
+    val hasImageBg = ThemeConfig.hasImageBg(isDark)
+    val bgImagePath = if (isDark) ThemeConfig.bgImageDark else ThemeConfig.bgImageLight
+    val blur = if (isDark) {
+        ThemeConfig.bgImageNBlurring
+    } else {
+        ThemeConfig.bgImageBlurring
+    }
+
+    if (hasImageBg && !bgImagePath.isNullOrBlank()) {
+        if (ThemeConfig.enableBlur) {
+            AsyncImage(
+                model = bgImagePath,
+                contentDescription = null,
+                imageLoader = org.koin.compose.koinInject(),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .hazeSource(hazeState),
+                contentScale = ContentScale.Crop
+            )
+        } else {
+            AsyncImage(
+                model = bgImagePath,
+                contentDescription = null,
+                imageLoader = org.koin.compose.koinInject(),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .blur(blur.dp),
+                contentScale = ContentScale.Crop
+            )
+        }
+    }
+}

+ 19 - 8
app/src/main/java/io/legado/app/ui/widget/components/FloatingBottomBar.kt

@@ -65,6 +65,7 @@ import com.kyant.backdrop.shadow.Shadow
 import com.kyant.capsule.ContinuousCapsule
 import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.animation.InteractiveHighlight
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.launch
@@ -110,12 +111,19 @@ fun FloatingBottomBar(
     backdrop: Backdrop,
     tabsCount: Int,
     isBlurEnabled: Boolean = true,
+    hasCustomIcons: Boolean = false,
     content: @Composable RowScope.() -> Unit
 ) {
     val isInLightTheme = !LegadoTheme.isDark
-    val accentColor = LegadoTheme.colorScheme.primary
-    val containerColor = if (isBlurEnabled) {
-        LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.4f)
+    val accentColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.themeColor != 0) {
+        Color(ThemeConfig.themeColor)
+    } else {
+        LegadoTheme.colorScheme.primary
+    }
+    val containerColor = if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+        Color(ThemeConfig.secondaryThemeColor).copy(alpha = if (isBlurEnabled) ThemeConfig.bottomBarBlurAlpha / 100f else 1f)
+    } else if (isBlurEnabled) {
+        LegadoTheme.colorScheme.surfaceContainer.copy(alpha = ThemeConfig.bottomBarBlurAlpha / 100f)
     } else {
         LegadoTheme.colorScheme.surfaceContainer
     }
@@ -250,8 +258,8 @@ fun FloatingBottomBar(
                     effects = {
                         if (isBlurEnabled) {
                             vibrancy()
-                            blur(8f.dp.toPx())
-                            lens(24f.dp.toPx(), 24f.dp.toPx())
+                            blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx())
+                            lens(ThemeConfig.bottomBarLensRadius.dp.toPx(), ThemeConfig.bottomBarLensRadius.dp.toPx())
                         }
                     },
                     highlight = {
@@ -307,8 +315,8 @@ fun FloatingBottomBar(
                             if (isBlurEnabled) {
                                 val progress = dampedDragAnimation.pressProgress
                                 vibrancy()
-                                blur(8f.dp.toPx())
-                                lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress)
+                                blur(ThemeConfig.bottomBarBlurRadius.toFloat().dp.toPx())
+                                lens(ThemeConfig.bottomBarLensRadius.dp.toPx() * progress, ThemeConfig.bottomBarLensRadius.dp.toPx() * progress)
                             }
                         },
                         highlight = {
@@ -331,7 +339,10 @@ fun FloatingBottomBar(
                     )
                     .height(56.dp)
                     .padding(horizontal = 4.dp)
-                    .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)),
+                    .then(
+                        if (hasCustomIcons) Modifier
+                        else Modifier.graphicsLayer(colorFilter = ColorFilter.tint(accentColor))
+                    ),
                 verticalAlignment = Alignment.CenterVertically,
                 content = content
             )

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

@@ -20,7 +20,15 @@ object GlassDefaults {
         }
     }
 
-    // 默认的玻璃效果透明度值,方便全局修改
+    @Composable
+    fun secondaryColorOr(fallback: @Composable () -> Color): Color {
+        return if (ThemeConfig.enableDeepPersonalization && ThemeConfig.secondaryThemeColor != 0) {
+            Color(ThemeConfig.secondaryThemeColor)
+        } else {
+            fallback()
+        }
+    }
+
     val DefaultBlurAlpha = 0.36f
     val ThickBlurAlpha = 0.72f
     val TransparentAlpha = 0f

+ 96 - 22
app/src/main/java/io/legado/app/ui/widget/components/SplicedColumnGroup.kt

@@ -1,35 +1,67 @@
 package io.legado.app.ui.widget.components
 
 import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.border
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.unit.dp
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.divider.SettingItemDivider
 import io.legado.app.ui.widget.components.title.AdaptiveTitle
 import top.yukonga.miuix.kmp.basic.Card as MiuixCard
 
-/**
- * Settings Group Container by https://github.com/wxxsfxyzm/InstallerX-Revived
- * Edit by @Kudomaga
- * @param title the title of the group
- * @param content a list of composable that will be displayed in the group
- * @param modifier Modifier
- */
+val LocalSplicedColumnGroupState = compositionLocalOf { SplicedColumnGroupState(
+    enableItemDivider = false,
+    currentIndex = { 0 },
+    incrementIndex = { }
+) }
+
+data class SplicedColumnGroupState(
+    val enableItemDivider: Boolean,
+    val currentIndex: () -> Int,
+    val incrementIndex: () -> Unit
+)
+
 @Composable
 fun SplicedColumnGroup(
     modifier: Modifier = Modifier,
     title: String = "",
-    content: @Composable ColumnScope.() -> Unit,
+    items: @Composable ColumnScope.() -> Unit,
 ) {
     val composeEngine = LegadoTheme.composeEngine
+    val enableBorder = ThemeConfig.enableContainerBorder
+    val borderWidth = ThemeConfig.containerBorderWidth.dp
+    val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+        Color(ThemeConfig.containerBorderColor)
+    } else {
+        MaterialTheme.colorScheme.outline
+    }
+    val enableItemDivider = ThemeConfig.enableItemDivider
+    val currentIndex = remember { mutableIntStateOf(0) }
+
+    val groupState = remember {
+        SplicedColumnGroupState(
+            enableItemDivider = enableItemDivider,
+            currentIndex = { currentIndex.intValue },
+            incrementIndex = { currentIndex.intValue++ }
+        )
+    }
+
     Column(modifier = modifier.padding(top = 8.dp, bottom = 8.dp)) {
         if (title.isNotEmpty()) {
             AdaptiveTitle(
@@ -38,28 +70,70 @@ fun SplicedColumnGroup(
             )
         }
 
-        if (ThemeResolver.isMiuixEngine(composeEngine)) {
-            MiuixCard {
+        CompositionLocalProvider(LocalSplicedColumnGroupState provides groupState) {
+            if (ThemeResolver.isMiuixEngine(composeEngine)) {
+                MiuixCard {
+                    Column(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .animateContentSize()
+                            .clip(RoundedCornerShape(16.dp))
+                    ) {
+                        currentIndex.intValue = 0
+                        items()
+                    }
+                }
+            } else {
                 Column(
                     modifier = Modifier
                         .fillMaxWidth()
                         .animateContentSize()
                         .clip(RoundedCornerShape(16.dp))
+                        .then(
+                            if (enableBorder) {
+                                Modifier.border(
+                                    width = borderWidth,
+                                    color = borderColor,
+                                    shape = RoundedCornerShape(16.dp)
+                                )
+                            } else {
+                                Modifier
+                            }
+                        ),
+                    verticalArrangement = if (enableBorder) {
+                        Arrangement.spacedBy(0.dp)
+                    } else {
+                        Arrangement.spacedBy(2.dp)
+                    }
                 ) {
-                    content()
+                    currentIndex.intValue = 0
+                    items()
                 }
             }
-
-        } else {
-            Column(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .animateContentSize()
-                    .clip(RoundedCornerShape(16.dp)),
-                verticalArrangement = Arrangement.spacedBy(2.dp)
-            ) {
-                content()
-            }
         }
     }
+}
+
+@Composable
+fun SplicedColumnDivider() {
+    val groupState = LocalSplicedColumnGroupState.current
+    if (groupState.enableItemDivider && groupState.currentIndex() > 0) {
+        SettingItemDivider()
+    }
+    groupState.incrementIndex()
+}
+
+@Composable
+fun SettingItemWithDivider(
+    modifier: Modifier = Modifier,
+    content: @Composable ColumnScope.() -> Unit
+) {
+    val groupState = LocalSplicedColumnGroupState.current
+    if (groupState.enableItemDivider && groupState.currentIndex() > 0) {
+        SettingItemDivider()
+    }
+    Column(modifier = modifier.fillMaxWidth()) {
+        groupState.incrementIndex()
+        content()
+    }
 }

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

@@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.card
 
 import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.ColumnScope
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -40,7 +41,26 @@ private fun BaseCard(
 ) {
     val resolvedContainerColor = (containerColor ?: LegadoTheme.colorScheme.secondaryContainer)
         .let { it.copy(alpha = it.alpha * alpha) }
-    if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
+    val isTransparent = containerColor == Color.Transparent
+    val shape = RoundedCornerShape(cornerRadius)
+
+    if (isTransparent) {
+        val clickableModifier = if (onClick != null || onLongClick != null) {
+            modifier
+                .clip(shape)
+                .combinedClickable(
+                    onClick = { onClick?.invoke() },
+                    onLongClick = onLongClick
+                )
+        } else {
+            modifier.clip(shape)
+        }
+        Box(
+            modifier = clickableModifier
+        ) {
+            Column(content = content)
+        }
+    } else if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
         val colors = MiuixCardDefaults.defaultColors(
             color = resolvedContainerColor,
             contentColor = contentColor ?: LegadoTheme.colorScheme.onSurface

+ 5 - 2
app/src/main/java/io/legado/app/ui/widget/components/card/TextCard.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.widget.components.card
 
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.padding
@@ -35,7 +36,8 @@ fun TextCard(
     verticalPadding: Dp = 4.dp,
     iconSize: Dp = 14.dp,
     spacing: Dp = 4.dp,
-    textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized
+    textStyle: TextStyle = LegadoTheme.typography.labelSmallEmphasized,
+    border: BorderStroke? = null
 ) {
     val defaultBackground = LegadoTheme.colorScheme.surfaceContainer
     val defaultContent = LegadoTheme.colorScheme.onSurface
@@ -48,7 +50,8 @@ fun TextCard(
         cornerRadius = cornerRadius,
         containerColor = finalBackgroundColor,
         contentColor = finalContentColor,
-        onClick = onClick
+        onClick = onClick,
+        border = border
     ) {
         Row(
             modifier = Modifier.padding(

+ 45 - 0
app/src/main/java/io/legado/app/ui/widget/components/divider/SettingItemDivider.kt

@@ -0,0 +1,45 @@
+package io.legado.app.ui.widget.components.divider
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+
+@Composable
+fun SettingItemDivider(
+    modifier: Modifier = Modifier
+) {
+    if (!ThemeConfig.enableItemDivider) return
+
+    val thickness = ThemeConfig.itemDividerWidth.dp
+    val lengthPercent = ThemeConfig.itemDividerLength / 100f
+    val dividerColor = if (ThemeConfig.itemDividerColor != 0) {
+        Color(ThemeConfig.itemDividerColor)
+    } else {
+        Color.Gray.copy(alpha = 0.3f)
+    }
+
+    Box(
+        modifier = modifier
+            .fillMaxWidth(),
+        contentAlignment = Alignment.Center
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxWidth(lengthPercent)
+                .height(thickness)
+                .clip(CircleShape)
+                .background(dividerColor)
+        )
+    }
+}

+ 17 - 2
app/src/main/java/io/legado/app/ui/widget/components/explore/ExploreKindItem.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.widget.components.explore
 
+import androidx.compose.foundation.BorderStroke
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -16,6 +17,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.data.entities.rule.ExploreKind
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.text.AppText
@@ -38,13 +40,24 @@ fun ExploreKindItem(
 
         val cornerRadius = 12.dp
 
+        val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder
+        val borderWidth = (ThemeConfig.containerBorderWidth - 0.8f).coerceAtLeast(0.1f).dp
+        val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+            androidx.compose.ui.graphics.Color(ThemeConfig.containerBorderColor)
+        } else {
+            LegadoTheme.colorScheme.outline
+        }
+
         if (isClickable) {
             GlassCard(
                 onClick = onClick,
                 cornerRadius = cornerRadius,
                 containerColor = backgroundColor,
                 contentColor = LegadoTheme.colorScheme.onSurface,
-                modifier = modifier
+                modifier = modifier,
+                border = if (enableBorder) {
+                    BorderStroke(borderWidth, borderColor)
+                } else null
             ) {
                 KindText(
                     text = displayText,
@@ -58,7 +71,9 @@ fun ExploreKindItem(
                 containerColor = backgroundColor,
                 contentColor = LegadoTheme.colorScheme.primary,
                 modifier = modifier,
-                border = CardDefaults.outlinedCardBorder()
+                border = if (enableBorder) {
+                    BorderStroke(borderWidth, borderColor)
+                } else BorderStroke(1.dp, LegadoTheme.colorScheme.outline)
             ) {
                 KindText(
                     text = displayText,

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

@@ -39,6 +39,7 @@ import io.legado.app.App
 import io.legado.app.data.entities.rule.ExploreKind
 import io.legado.app.ui.widget.components.explore.ExploreKindUiUseCase
 import io.legado.app.help.source.getExploreInfoMap
+import io.legado.app.ui.config.themeConfig.ThemeConfig
 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.text.AppText
@@ -321,7 +322,11 @@ private fun ExploreKindCompactTextField(
     val interactionSource = remember { MutableInteractionSource() }
     val isFocused by interactionSource.collectIsFocusedAsState()
     val shape = RoundedCornerShape(10.dp)
-    val borderColor = if (isFocused) {
+    val enableBorder = ThemeConfig.enableDeepPersonalization && ThemeConfig.enableContainerBorder
+    val borderWidth = ThemeConfig.containerBorderWidth.dp
+    val borderColor = if (ThemeConfig.containerBorderColor != 0) {
+        Color(ThemeConfig.containerBorderColor)
+    } else if (isFocused) {
         LegadoTheme.colorScheme.primary
     } else {
         Color.Transparent
@@ -338,7 +343,7 @@ private fun ExploreKindCompactTextField(
             .height(34.dp)
             .clip(shape)
             .background(backgroundColor)
-            .border(width = 1.dp, color = borderColor, shape = shape),
+            .border(width = borderWidth, color = borderColor, shape = shape),
         decorationBox = { innerTextField ->
             Box(
                 modifier = Modifier

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

@@ -96,7 +96,7 @@ object AppIcons {
 
     val Replay: ImageVector
         @Composable
-        get() = if (isMiuix) MiuixIcons.Refresh ?: Icons.Default.Replay else Icons.Default.Replay
+        get() = if (isMiuix) MiuixIcons.Refresh else Icons.Default.Replay
 
     @Composable
     fun mainDestination(destination: MainDestination, selected: Boolean): ImageVector {

+ 3 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/ClickableSettingItem.kt

@@ -9,6 +9,7 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.vector.ImageVector
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.SplicedColumnDivider
 import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
 import top.yukonga.miuix.kmp.preference.ArrowPreference
 
@@ -24,6 +25,8 @@ fun ClickableSettingItem(
     onClick: () -> Unit
 ) {
     val composeEngine = LegadoTheme.composeEngine
+    SplicedColumnDivider()
+
     if (ThemeResolver.isMiuixEngine(composeEngine)) {
         ArrowPreference(
             title = title,

+ 38 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/CompactSettingItems.kt

@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.ChevronRight
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
@@ -32,6 +33,8 @@ import io.legado.app.ui.widget.components.ValueStepper
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import top.yukonga.miuix.kmp.basic.BasicComponent
+import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
+import top.yukonga.miuix.kmp.preference.ArrowPreference
 import top.yukonga.miuix.kmp.preference.SwitchPreference
 import top.yukonga.miuix.kmp.preference.WindowDropdownPreference
 import top.yukonga.miuix.kmp.basic.Slider as MiuixSlider
@@ -251,3 +254,38 @@ fun CompactSwitchSettingItem(
         )
     }
 }
+
+@Composable
+fun CompactClickableSettingItem(
+    title: String,
+    description: String? = null,
+    imageVector: ImageVector? = null,
+    color: Color? = MaterialTheme.colorScheme.surface,
+    shape: Shape = MaterialTheme.shapes.small,
+    onClick: () -> Unit
+) {
+    if (ThemeResolver.isMiuixEngine(composeEngine)) {
+        ArrowPreference(
+            title = title,
+            summary = description,
+            insideMargin = BasicComponentDefaults.InsideMargin,
+            onClick = onClick
+        )
+    } else {
+        SettingItem(
+            title = title,
+            description = description,
+            imageVector = imageVector,
+            color = color,
+            shape = shape,
+            trailingContent = {
+                Icon(
+                    imageVector = Icons.Default.ChevronRight,
+                    contentDescription = null,
+                    tint = MaterialTheme.colorScheme.onSurfaceVariant
+                )
+            },
+            onClick = onClick
+        )
+    }
+}

+ 2 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/InputSettingItem.kt

@@ -21,6 +21,7 @@ import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
+import io.legado.app.ui.widget.components.SplicedColumnDivider
 import io.legado.app.ui.widget.components.text.AppText
 import top.yukonga.miuix.kmp.basic.BasicComponent
 import top.yukonga.miuix.kmp.basic.TextField as MiuixTextField
@@ -46,6 +47,7 @@ fun InputSettingItem(
     }
 
     val composeEngine = LegadoTheme.composeEngine
+    SplicedColumnDivider()
 
     if (ThemeResolver.isMiuixEngine(composeEngine)) {
         Column(modifier = Modifier.fillMaxWidth()) {

Some files were not shown because too many files changed in this diff