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

Merge remote-tracking branch 'origin/main' into codex/tts-multi-speaker

# Conflicts:
#	app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
#	app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt
#	app/src/main/java/io/legado/app/model/ReadAloud.kt
#	app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
#	app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
#	app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
#	app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt
#	app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt
#	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/strings.xml
HapeLee 3 месяцев назад
Родитель
Сommit
496d66d2c1
100 измененных файлов с 3902 добавлено и 1534 удалено
  1. 117 0
      .github/scripts/publish-update-manifest.sh
  2. 47 12
      .github/workflows/auto-release.yml
  3. 0 21
      .github/workflows/build-apk-for-user.yml
  4. 0 52
      .github/workflows/codeql.yml
  5. 16 0
      .github/workflows/release.yml
  6. 1 0
      app/build.gradle.kts
  7. 9 0
      app/src/main/AndroidManifest.xml
  8. 14 8
      app/src/main/java/io/legado/app/base/BaseActivity.kt
  9. 47 0
      app/src/main/java/io/legado/app/base/BaseComposeActivity.kt
  10. 2 0
      app/src/main/java/io/legado/app/constant/AppPattern.kt
  11. 2 1
      app/src/main/java/io/legado/app/constant/IntentAction.kt
  12. 9 1
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  13. 33 16
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  14. 1 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  15. 3 6
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  16. 24 6
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  17. 8 0
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  18. 38 2
      app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt
  19. 19 0
      app/src/main/java/io/legado/app/domain/model/PlaybackTimer.kt
  20. 2 5
      app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt
  21. 4 1
      app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt
  22. 30 0
      app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt
  23. 13 3
      app/src/main/java/io/legado/app/help/book/BookExtensions.kt
  24. 23 8
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  25. 52 0
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  26. 10 0
      app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt
  27. 23 89
      app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt
  28. 73 37
      app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt
  29. 46 4
      app/src/main/java/io/legado/app/help/http/BackstageWebView.kt
  30. 2 2
      app/src/main/java/io/legado/app/help/http/RequestMethod.kt
  31. 2 0
      app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
  32. 21 0
      app/src/main/java/io/legado/app/help/storage/Restore.kt
  33. 33 15
      app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt
  34. 177 41
      app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt
  35. 74 0
      app/src/main/java/io/legado/app/help/webView/WebViewDataCleaner.kt
  36. 1 22
      app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt
  37. 6 4
      app/src/main/java/io/legado/app/model/AudioPlay.kt
  38. 82 17
      app/src/main/java/io/legado/app/model/CacheBookModel.kt
  39. 10 1
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  40. 12 0
      app/src/main/java/io/legado/app/model/ReadAloudLayoutPolicy.kt
  41. 146 61
      app/src/main/java/io/legado/app/model/ReadBook.kt
  42. 56 1
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt
  43. 18 2
      app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt
  44. 10 10
      app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt
  45. 21 7
      app/src/main/java/io/legado/app/model/webBook/BookContent.kt
  46. 13 12
      app/src/main/java/io/legado/app/service/AudioPlayService.kt
  47. 101 12
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  48. 7 8
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  49. 22 9
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  50. 2 2
      app/src/main/java/io/legado/app/ui/ai/chat/AiGeneratedMessageContent.kt
  51. 1 0
      app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt
  52. 3 4
      app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt
  53. 3 4
      app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt
  54. 4 4
      app/src/main/java/io/legado/app/ui/book/audio/AudioPlayActivity.kt
  55. 101 22
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt
  56. 16 1
      app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt
  57. 24 13
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt
  58. 5 3
      app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt
  59. 25 3
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt
  60. 19 6
      app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt
  61. 9 6
      app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt
  62. 31 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoBackdropStyle.kt
  63. 5 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  64. 4 3
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  65. 117 37
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  66. 10 4
      app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
  67. 23 6
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  68. 101 77
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  69. 9 6
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditViewModel.kt
  70. 29 10
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  71. 4 1
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt
  72. 65 7
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt
  73. 41 9
      app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt
  74. 3 3
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/MangaVH.kt
  75. 43 0
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonFrame.kt
  76. 9 2
      app/src/main/java/io/legado/app/ui/book/manga/recyclerview/WebtoonRecyclerView.kt
  77. 1 5
      app/src/main/java/io/legado/app/ui/book/read/ReadBookColorTheme.kt
  78. 37 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  79. 275 49
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  80. 321 268
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  81. 91 9
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  82. 9 29
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  83. 6 4
      app/src/main/java/io/legado/app/ui/book/read/ReadBookSearchBar.kt
  84. 120 20
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  85. 0 305
      app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt
  86. 618 0
      app/src/main/java/io/legado/app/ui/book/read/TextActionSelectionMenu.kt
  87. 2 9
      app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt
  88. 61 3
      app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt
  89. 2 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/TextLine.kt
  90. 2 2
      app/src/main/java/io/legado/app/ui/book/read/page/entities/column/TextColumn.kt
  91. 1 1
      app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt
  92. 27 13
      app/src/main/java/io/legado/app/ui/book/read/page/provider/TextChapterLayout.kt
  93. 2 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt
  94. 9 4
      app/src/main/java/io/legado/app/ui/book/read/sheet/ChangeChapterSourceSheet.kt
  95. 7 10
      app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt
  96. 26 32
      app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt
  97. 25 13
      app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt
  98. 55 14
      app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt
  99. 18 6
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt
  100. 1 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleEditSheet.kt

+ 117 - 0
.github/scripts/publish-update-manifest.sh

@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+channel="${1:-}"
+version="${2:-}"
+
+if [[ "$channel" != "official" && "$channel" != "beta" ]]; then
+  echo "Unsupported update channel: $channel" >&2
+  exit 1
+fi
+if [[ -z "$version" ]]; then
+  echo "Release version is required" >&2
+  exit 1
+fi
+: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
+: "${RUNNER_TEMP:?RUNNER_TEMP is required}"
+
+manifest_branch="update-manifests"
+manifest_dir="$RUNNER_TEMP/$manifest_branch"
+release_tmp="$manifest_dir/release.tmp"
+release_list_tmp="$manifest_dir/releases.tmp"
+
+write_manifest() {
+  local source_file="$1"
+  local target_channel="$2"
+  local expected_version="$3"
+  local target_file="$manifest_dir/$target_channel.json"
+  local target_tmp="$target_file.tmp"
+  local expected_prerelease="false"
+  if [[ "$target_channel" == "beta" ]]; then
+    expected_prerelease="true"
+  fi
+
+  jq -e \
+    --arg version "$expected_version" \
+    --argjson prerelease "$expected_prerelease" \
+    'if .tag_name == $version and .prerelease == $prerelease and (.assets | type == "array") then
+      {
+        tag_name,
+        name,
+        body: (.body // ""),
+        prerelease,
+        created_at,
+        assets: [.assets[] | {
+          browser_download_url,
+          content_type,
+          created_at,
+          download_count: 0,
+          id,
+          name,
+          state,
+          url
+        }]
+      }
+    else
+      error("Release response does not match the requested channel and version")
+    end' \
+    "$source_file" > "$target_tmp"
+  mv "$target_tmp" "$target_file"
+}
+
+rm -rf "$manifest_dir"
+if git fetch --no-tags origin "$manifest_branch"; then
+  git worktree add --detach "$manifest_dir" FETCH_HEAD
+else
+  git worktree add --detach "$manifest_dir" HEAD
+  git -C "$manifest_dir" checkout --orphan "$manifest_branch"
+  git -C "$manifest_dir" rm -q -rf --ignore-unmatch .
+fi
+trap 'git worktree remove --force "$manifest_dir" >/dev/null 2>&1 || true' EXIT
+
+gh api \
+  -H "Accept: application/vnd.github+json" \
+  -H "X-GitHub-Api-Version: 2026-03-10" \
+  "repos/$GITHUB_REPOSITORY/releases/tags/$version" > "$release_tmp"
+write_manifest "$release_tmp" "$channel" "$version"
+
+other_channel="official"
+if [[ "$channel" == "official" ]]; then
+  other_channel="beta"
+fi
+
+if [[ ! -f "$manifest_dir/$other_channel.json" ]]; then
+  if [[ "$other_channel" == "official" ]]; then
+    gh api \
+      -H "Accept: application/vnd.github+json" \
+      -H "X-GitHub-Api-Version: 2026-03-10" \
+      "repos/$GITHUB_REPOSITORY/releases/latest" > "$release_tmp"
+    other_version="$(jq -r '.tag_name' "$release_tmp")"
+    write_manifest "$release_tmp" "$other_channel" "$other_version"
+  else
+    gh api \
+      -H "Accept: application/vnd.github+json" \
+      -H "X-GitHub-Api-Version: 2026-03-10" \
+      "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$release_list_tmp"
+    if jq -e \
+      '[.[] | select(.draft == false and .prerelease == true)] | max_by(.created_at) | select(. != null)' \
+      "$release_list_tmp" > "$release_tmp"; then
+      other_version="$(jq -r '.tag_name' "$release_tmp")"
+      write_manifest "$release_tmp" "$other_channel" "$other_version"
+    fi
+  fi
+fi
+rm -f "$release_tmp" "$release_list_tmp"
+
+git -C "$manifest_dir" config user.name "github-actions[bot]"
+git -C "$manifest_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+git -C "$manifest_dir" add --all
+
+if git -C "$manifest_dir" diff --cached --quiet; then
+  echo "$channel update manifest is already current"
+  exit 0
+fi
+
+git -C "$manifest_dir" commit -m "Update release manifests for $version"
+git -C "$manifest_dir" push origin "HEAD:refs/heads/$manifest_branch"

+ 47 - 12
.github/workflows/auto-release.yml

@@ -1,4 +1,4 @@
-name: Auto Release
+name: Auto Build
 
 
 on:
 on:
   push:
   push:
@@ -19,6 +19,13 @@ on:
     types:
     types:
       - completed
       - completed
   workflow_dispatch:
   workflow_dispatch:
+  workflow_call:
+    inputs:
+      publish:
+        description: '构建完成后创建 GitHub Release 并发布到 Telegram'
+        required: false
+        type: boolean
+        default: false
 
 
 permissions:
 permissions:
   contents: write
   contents: write
@@ -82,7 +89,7 @@ jobs:
                 LAST_TAG_FOR_LOG=$(git tag --sort=-committerdate | head -n 2 | tail -n 1)
                 LAST_TAG_FOR_LOG=$(git tag --sort=-committerdate | head -n 2 | tail -n 1)
             fi
             fi
           
           
-          elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
+          elif [[ ("${{ github.event_name }}" == "push" || "${{ inputs.publish }}" == "true") && "${{ github.ref }}" == "refs/heads/main" ]]; then
             # 标记为测试版 (VERSION_SUFFIX=1 或其他/空),且在 main 分支推送
             # 标记为测试版 (VERSION_SUFFIX=1 或其他/空),且在 main 分支推送
           
           
             # 查找匹配当前 CURRENT_VERSION 的上一个 BETA TAG (e.g., 3.26.4-beta.X)
             # 查找匹配当前 CURRENT_VERSION 的上一个 BETA TAG (e.g., 3.26.4-beta.X)
@@ -242,15 +249,27 @@ jobs:
           if-no-files-found: ignore
           if-no-files-found: ignore
           path: ${{ github.workspace }}/mapping/mapping.txt
           path: ${{ github.workspace }}/mapping/mapping.txt
 
 
+      - name: Save release metadata
+        if: matrix.type == 'release'
+        run: |
+          {
+            echo "VERSIONL=${{ needs.prepare.outputs.versionL }}"
+            echo "LATEST_TAG=${{ needs.prepare.outputs.latest_tag }}"
+            echo "IS_RELEASE=${{ needs.prepare.outputs.is_release }}"
+          } > release-metadata.env
+
+      - name: Upload Release Metadata
+        if: matrix.type == 'release'
+        uses: actions/upload-artifact@v4
+        with:
+          name: release-metadata
+          if-no-files-found: error
+          path: release-metadata.env
+
   create_release:
   create_release:
     needs: [ prepare, build ]
     needs: [ prepare, build ]
-    # 仅在所有者的 push 到 main 分支时执行发布
-    if: |
-      github.event_name == 'push' &&
-      github.ref == 'refs/heads/main' &&
-      github.actor == 'HapeLee' &&
-      (needs.prepare.outputs.build_number > 0 || needs.prepare.outputs.is_release == 'true') &&
-      success()
+    # 只有 Release APK workflow 调用时才发布;普通 Auto Build 仍然只构建产物。
+    if: inputs.publish && github.actor == 'HapeLee' && github.ref == 'refs/heads/main'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
       - uses: actions/checkout@v5
       - uses: actions/checkout@v5
@@ -262,10 +281,9 @@ jobs:
       - name: 下载所有 APK Artifacts
       - name: 下载所有 APK Artifacts
         uses: actions/download-artifact@v4
         uses: actions/download-artifact@v4
         with:
         with:
-          pattern: '*-apk-*'
+          pattern: '*-apk-release'
           path: artifacts
           path: artifacts
           merge-multiple: true
           merge-multiple: true
-          if-no-artifact-found: error
 
 
       # 2. 下载 mapping 文件
       # 2. 下载 mapping 文件
       - name: 下载 Mapping Artifacts
       - name: 下载 Mapping Artifacts
@@ -311,7 +329,11 @@ jobs:
           LAST_TAG="${{ needs.prepare.outputs.latest_tag }}"
           LAST_TAG="${{ needs.prepare.outputs.latest_tag }}"
           IS_RELEASE="${{ needs.prepare.outputs.is_release }}"
           IS_RELEASE="${{ needs.prepare.outputs.is_release }}"
           
           
-          COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
+          if [ -n "$LAST_TAG" ] && git rev-parse -q --verify "refs/tags/$LAST_TAG" >/dev/null; then
+            COMMIT_LOG=$(git log "$LAST_TAG"..HEAD --pretty=format:"* %s" --no-merges)
+          else
+            COMMIT_LOG=$(git log -1 --pretty=format:"* %s")
+          fi
           
           
           if [ -z "$COMMIT_LOG" ]; then
           if [ -z "$COMMIT_LOG" ]; then
               CONTENT="- 没有新的 Commit 记录."
               CONTENT="- 没有新的 Commit 记录."
@@ -361,6 +383,19 @@ jobs:
         env:
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
 
+      - name: 发布静态更新清单
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          VERSION: ${{ needs.prepare.outputs.versionL }}
+          IS_RELEASE: ${{ needs.prepare.outputs.is_release }}
+        run: |
+          if [[ "$IS_RELEASE" == "true" ]]; then
+            CHANNEL="official"
+          else
+            CHANNEL="beta"
+          fi
+          bash .github/scripts/publish-update-manifest.sh "$CHANNEL" "$VERSION"
+
       - name: 发布到 Telegram 频道
       - name: 发布到 Telegram 频道
         env:
         env:
           TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
           TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}

+ 0 - 21
.github/workflows/build-apk-for-user.yml

@@ -7,10 +7,6 @@ on:
         description: '要构建的分支'
         description: '要构建的分支'
         required: true
         required: true
         default: 'trae/solo-agent-vhkVM3'
         default: 'trae/solo-agent-vhkVM3'
-      release_tag:
-        description: 'Release 标签 (留空则不上传到 Release)'
-        required: false
-        default: ''
 
 
 permissions:
 permissions:
   contents: write
   contents: write
@@ -90,20 +86,3 @@ jobs:
         with:
         with:
           name: legado-apks-zip-${{ github.run_number }}
           name: legado-apks-zip-${{ github.run_number }}
           path: legado-debug-apks-*.zip
           path: legado-debug-apks-*.zip
-
-      - name: Create Release and Upload
-        if: ${{ github.event.inputs.release_tag != '' }}
-        uses: softprops/action-gh-release@v2
-        with:
-          tag_name: ${{ github.event.inputs.release_tag }}
-          name: Legado Debug Build ${{ github.event.inputs.release_tag }}
-          body: |
-            自动构建的 Debug 版本
-            - 分支: ${{ github.event.inputs.branch }}
-            - 构建号: ${{ github.run_number }}
-          prerelease: true
-          files: |
-            app/build/outputs/apk/app/debug/*.apk
-            legado-debug-apks-*.zip
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 0 - 52
.github/workflows/codeql.yml

@@ -1,52 +0,0 @@
-name: "CodeQL"
-
-on:
-  push:
-    branches: [ "main" ]
-  pull_request:
-    branches: [ "main" ]
-  schedule:
-    - cron: '25 3 * * 1'
-
-jobs:
-  analyze:
-    name: Analyze (java-kotlin)
-    runs-on: ubuntu-latest
-    permissions:
-      security-events: write
-      packages: read
-      actions: read
-      contents: read
-
-    strategy:
-      fail-fast: false
-      matrix:
-        language: [ 'java-kotlin' ]
-
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v4
-
-      - name: Set up JDK 21
-        uses: actions/setup-java@v4
-        with:
-          java-version: '21'
-          distribution: 'temurin'
-
-      - name: Initialize CodeQL
-        uses: github/codeql-action/init@v3
-        with:
-          languages: ${{ matrix.language }}
-          build-mode: manual
-
-      - name: Remove Aliyun mirrors (unreliable in CI)
-        run: |
-          sed -i '/maven.aliyun.com/d' settings.gradle
-
-      - name: Build with Gradle
-        run: ./gradlew assembleAppDebug --no-daemon --no-configuration-cache -Dorg.gradle.warning.mode=none
-
-      - name: Perform CodeQL Analysis
-        uses: github/codeql-action/analyze@v3
-        with:
-          category: "/language:${{ matrix.language }}"

+ 16 - 0
.github/workflows/release.yml

@@ -0,0 +1,16 @@
+name: Release APK
+
+on:
+  workflow_dispatch:
+
+permissions:
+  actions: read
+  contents: write
+
+jobs:
+  release:
+    if: github.actor == 'HapeLee' && github.ref == 'refs/heads/main'
+    uses: ./.github/workflows/auto-release.yml
+    with:
+      publish: true
+    secrets: inherit

+ 1 - 0
app/build.gradle.kts

@@ -173,6 +173,7 @@ dependencies {
     "baselineProfile"(project(":baselineprofile"))
     "baselineProfile"(project(":baselineprofile"))
     coreLibraryDesugaring(libs.desugar)
     coreLibraryDesugaring(libs.desugar)
     testImplementation(libs.junit)
     testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
     androidTestImplementation(libs.bundles.androidTest)
     androidTestImplementation(libs.bundles.androidTest)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlinx.collections.immutable)
     implementation(libs.kotlinx.collections.immutable)

+ 9 - 0
app/src/main/AndroidManifest.xml

@@ -39,6 +39,7 @@
         <activity
         <activity
             android:name=".ui.main.LauncherW"
             android:name=".ui.main.LauncherW"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcherw">
             android:icon="@mipmap/launcherw">
             <intent-filter>
             <intent-filter>
@@ -54,6 +55,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher0"
             android:name=".ui.main.Launcher0"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher0">
             android:icon="@mipmap/launcher0">
             <intent-filter>
             <intent-filter>
@@ -70,6 +72,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher1"
             android:name=".ui.main.Launcher1"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher1">
             android:icon="@mipmap/launcher1">
             <intent-filter>
             <intent-filter>
@@ -86,6 +89,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher2"
             android:name=".ui.main.Launcher2"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher2">
             android:icon="@mipmap/launcher2">
             <intent-filter>
             <intent-filter>
@@ -102,6 +106,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher3"
             android:name=".ui.main.Launcher3"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher3">
             android:icon="@mipmap/launcher3">
             <intent-filter>
             <intent-filter>
@@ -118,6 +123,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher4"
             android:name=".ui.main.Launcher4"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher4">
             android:icon="@mipmap/launcher4">
             <intent-filter>
             <intent-filter>
@@ -134,6 +140,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher5"
             android:name=".ui.main.Launcher5"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher5">
             android:icon="@mipmap/launcher5">
             <intent-filter>
             <intent-filter>
@@ -150,6 +157,7 @@
         <activity
         <activity
             android:name=".ui.main.Launcher6"
             android:name=".ui.main.Launcher6"
             android:enabled="false"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:exported="true"
             android:icon="@mipmap/launcher6">
             android:icon="@mipmap/launcher6">
             <intent-filter>
             <intent-filter>
@@ -166,6 +174,7 @@
         <activity
         <activity
             android:name=".ui.main.MainActivity"
             android:name=".ui.main.MainActivity"
             android:alwaysRetainTaskState="true"
             android:alwaysRetainTaskState="true"
+            android:configChanges="uiMode"
             android:enableOnBackInvokedCallback="true"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             android:windowSoftInputMode="adjustResize"
             android:exported="true">
             android:exported="true">

+ 14 - 8
app/src/main/java/io/legado/app/base/BaseActivity.kt

@@ -168,7 +168,7 @@ abstract class BaseActivity<VB : ViewBinding>(
     open fun initTheme() {
     open fun initTheme() {
         when (getPrefString("app_theme", "0")) {
         when (getPrefString("app_theme", "0")) {
             "0" -> {
             "0" -> {
-                DynamicColors.applyToActivitiesIfAvailable(application)
+                DynamicColors.applyToActivityIfAvailable(this)
             }
             }
             "1" -> setTheme(R.style.Theme_Base_GR)
             "1" -> setTheme(R.style.Theme_Base_GR)
             "2" -> setTheme(R.style.Theme_Base_Lemon)
             "2" -> setTheme(R.style.Theme_Base_Lemon)
@@ -182,10 +182,8 @@ abstract class BaseActivity<VB : ViewBinding>(
             "10" -> setTheme(R.style.Theme_Base_Phoebe)
             "10" -> setTheme(R.style.Theme_Base_Phoebe)
             "11" -> setTheme(R.style.Theme_Base_Mujika)
             "11" -> setTheme(R.style.Theme_Base_Mujika)
             "12" -> {
             "12" -> {
-                if (AppConfig.customMode == "accent")
-                    setTheme(R.style.ThemeOverlay_WhiteBackground)
-
                 val colorImagePath = getPrefString(PreferKey.colorImage)
                 val colorImagePath = getPrefString(PreferKey.colorImage)
+                var colorImageApplied = false
                 if (!colorImagePath.isNullOrBlank()) {
                 if (!colorImagePath.isNullOrBlank()) {
                     val file = File(colorImagePath)
                     val file = File(colorImagePath)
                     if (file.exists()) {
                     if (file.exists()) {
@@ -200,18 +198,26 @@ abstract class BaseActivity<VB : ViewBinding>(
                                 .setContentBasedSource(scaledBitmap)
                                 .setContentBasedSource(scaledBitmap)
                                 .build()
                                 .build()
 
 
-                            DynamicColors.applyToActivitiesIfAvailable(application, options)
+                            DynamicColors.applyToActivityIfAvailable(this, options)
                             bitmap.recycle()
                             bitmap.recycle()
+                            colorImageApplied = true
                         }
                         }
                     }
                     }
-                }else{
-                    DynamicColors.applyToActivitiesIfAvailable(
-                        application,
+                }
+                if (!colorImageApplied) {
+                    // 取色图片缺失或解码失败时回退到种子色,
+                    // 否则该 Activity 完全拿不到动态配色,与 Compose 界面不一致
+                    DynamicColors.applyToActivityIfAvailable(
+                        this,
                         DynamicColorsOptions.Builder()
                         DynamicColorsOptions.Builder()
                             .setContentBasedSource(application.primaryColor)
                             .setContentBasedSource(application.primaryColor)
                             .build()
                             .build()
                     )
                     )
                 }
                 }
+
+                // 必须在动态取色之后应用,否则会被动态配色的 surface/background 覆盖
+                if (AppConfig.customMode == "accent")
+                    setTheme(R.style.ThemeOverlay_WhiteBackground)
             }
             }
 
 
             "13" -> setTheme(R.style.AppTheme_Transparent)
             "13" -> setTheme(R.style.AppTheme_Transparent)

+ 47 - 0
app/src/main/java/io/legado/app/base/BaseComposeActivity.kt

@@ -1,11 +1,14 @@
 package io.legado.app.base
 package io.legado.app.base
 
 
+import android.content.res.Configuration
 import android.os.Bundle
 import android.os.Bundle
 import android.os.Build
 import android.os.Build
 import androidx.activity.compose.setContent
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
 import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.SideEffect
+import androidx.compose.ui.graphics.toArgb
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.graphics.drawable.toDrawable
 import androidx.core.view.WindowCompat
 import androidx.core.view.WindowCompat
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
@@ -13,6 +16,8 @@ import io.legado.app.constant.Theme
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.help.config.ThemeConfigStore
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.ui.theme.AppTheme
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ThemeResolver
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.disableAutoFill
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.fullScreen
 import io.legado.app.utils.observeEvent
 import io.legado.app.utils.observeEvent
@@ -20,6 +25,7 @@ import io.legado.app.utils.setStatusBarColorAuto
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.themeColor
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.toggleSystemBar
 import io.legado.app.utils.windowSize
 import io.legado.app.utils.windowSize
+import top.yukonga.miuix.kmp.theme.MiuixTheme
 
 
 abstract class BaseComposeActivity(
 abstract class BaseComposeActivity(
     val fullScreen: Boolean = true,
     val fullScreen: Boolean = true,
@@ -45,6 +51,7 @@ abstract class BaseComposeActivity(
         // Compose 入口
         // Compose 入口
         setContent {
         setContent {
             AppTheme {
             AppTheme {
+                SyncWindowBackground()
                 Content()
                 Content()
             }
             }
         }
         }
@@ -56,6 +63,18 @@ abstract class BaseComposeActivity(
         observeLiveBus()
         observeLiveBus()
     }
     }
 
 
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        // manifest 声明 uiMode configChanges 后,切换深浅色不再 recreate,
+        // 而 AppCompat 手动回调本方法时不会走 View 树分发,需要自己同步给
+        // Compose(LocalConfiguration)并刷新系统栏与背景图
+        window.decorView.dispatchConfigurationChanged(newConfig)
+        setupSystemBar()
+        if (imageBg) {
+            upBackgroundImage()
+        }
+    }
+
     open fun setupSystemBar() {
     open fun setupSystemBar() {
         WindowCompat.setDecorFitsSystemWindows(window, false)
         WindowCompat.setDecorFitsSystemWindows(window, false)
 
 
@@ -73,11 +92,39 @@ abstract class BaseComposeActivity(
     open fun upBackgroundImage() {
     open fun upBackgroundImage() {
         try {
         try {
             ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
             ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
+                hasWindowBgImage = true
                 window.setBackgroundDrawable(it.toDrawable(resources))
                 window.setBackgroundDrawable(it.toDrawable(resources))
             }
             }
         } catch (_: Exception) {}
         } catch (_: Exception) {}
     }
     }
 
 
+    protected var hasWindowBgImage = false
+    private var lastWindowBgColor: Int? = null
+
+    /**
+     * 窗口背景色与 Compose 主题背景色同步。
+     * 窗口背景默认来自 XML 主题,与运行时计算的 Compose 主题色存在色差;
+     * 转场淡出、启动交接等场景露出窗口背景时会出现颜色跳变。
+     */
+    @Composable
+    private fun SyncWindowBackground() {
+        if (transparent) return
+        val backgroundColor = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
+            MiuixTheme.colorScheme.surface
+        } else {
+            LegadoTheme.colorScheme.background
+        }
+        SideEffect {
+            if (!hasWindowBgImage) {
+                val colorInt = backgroundColor.toArgb()
+                if (lastWindowBgColor != colorInt) {
+                    window.setBackgroundDrawable(colorInt.toDrawable())
+                    lastWindowBgColor = colorInt
+                }
+            }
+        }
+    }
+
     open fun observeLiveBus() {
     open fun observeLiveBus() {
         observeEvent<String>(EventBus.RECREATE) {
         observeEvent<String>(EventBus.RECREATE) {
             recreate()
             recreate()

+ 2 - 0
app/src/main/java/io/legado/app/constant/AppPattern.kt

@@ -6,6 +6,8 @@ import java.util.regex.Pattern
 object AppPattern {
 object AppPattern {
     val JS_PATTERN: Pattern =
     val JS_PATTERN: Pattern =
         Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
         Pattern.compile("<js>([\\w\\W]*?)</js>|@js:([\\w\\W]*)", Pattern.CASE_INSENSITIVE)
+    val WebJS_PATTERN: Pattern =
+        Pattern.compile("@webjs:([\\w\\W]{5,})", Pattern.CASE_INSENSITIVE)
     val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
     val EXP_PATTERN: Pattern = Pattern.compile("\\{\\{([\\w\\W]*?)\\}\\}")
 
 
     //匹配格式化后的图片格式
     //匹配格式化后的图片格式

+ 2 - 1
app/src/main/java/io/legado/app/constant/IntentAction.kt

@@ -13,6 +13,7 @@ object IntentAction {
     const val prevParagraph = "prevParagraph"
     const val prevParagraph = "prevParagraph"
     const val nextParagraph = "nextParagraph"
     const val nextParagraph = "nextParagraph"
     const val upTtsSpeechRate = "upTtsSpeechRate"
     const val upTtsSpeechRate = "upTtsSpeechRate"
+    const val syncReadAloudLayout = "syncReadAloudLayout"
     const val upTtsProgress = "upTtsProgress"
     const val upTtsProgress = "upTtsProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustSpeed = "adjustSpeed"
     const val adjustSpeed = "adjustSpeed"
@@ -22,4 +23,4 @@ object IntentAction {
     const val init = "init"
     const val init = "init"
     const val remove = "remove"
     const val remove = "remove"
     const val stopPlay = "stopPlay"
     const val stopPlay = "stopPlay"
-}
+}

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

@@ -135,7 +135,7 @@ object PreferKey {
     const val nightBrightness = "nightBrightness"
     const val nightBrightness = "nightBrightness"
     const val expandTextMenu = "expandTextMenu"
     const val expandTextMenu = "expandTextMenu"
     const val showSelectMenuIcon = "showSelectMenuIcon"
     const val showSelectMenuIcon = "showSelectMenuIcon"
-    const val textSelectMenuFilter = "textSelectMenuFilter"
+    const val textSelectMenuConfig = "textSelectMenuConfig"
     const val doublePageHorizontal = "doubleHorizontalPage"
     const val doublePageHorizontal = "doubleHorizontalPage"
     const val readUrlOpenInBrowser = "readUrlInBrowser"
     const val readUrlOpenInBrowser = "readUrlInBrowser"
     const val defaultBookTreeUri = "defaultBookTreeUri"
     const val defaultBookTreeUri = "defaultBookTreeUri"
@@ -180,6 +180,7 @@ object PreferKey {
     const val recordHeapDump = "recordHeapDump"
     const val recordHeapDump = "recordHeapDump"
     const val optimizeRender = "optimizeRender"
     const val optimizeRender = "optimizeRender"
     const val updateToVariant = "updateToVariant"
     const val updateToVariant = "updateToVariant"
+    const val autoCheckUpdateOnStart = "autoCheckUpdateOnStart"
     const val streamReadAloudAudio = "streamReadAloudAudio"
     const val streamReadAloudAudio = "streamReadAloudAudio"
     const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls"
     const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls"
     const val readAloudByMediaButton = "readAloudByMediaButton"
     const val readAloudByMediaButton = "readAloudByMediaButton"
@@ -207,6 +208,8 @@ object PreferKey {
     const val useFlexibleTopAppBar = "useFlexibleTopAppBar"
     const val useFlexibleTopAppBar = "useFlexibleTopAppBar"
     const val bookInfoFollowCoverColor = "bookInfoFollowCoverColor"
     const val bookInfoFollowCoverColor = "bookInfoFollowCoverColor"
     const val bookInfoBackgroundBlur = "bookInfoBackgroundBlur"
     const val bookInfoBackgroundBlur = "bookInfoBackgroundBlur"
+    const val bookInfoNetworkCoverBackground = "bookInfoNetworkCoverBackground"
+    const val bookInfoDefaultCoverBackground = "bookInfoDefaultCoverBackground"
     const val cBackground = "colorBackground"
     const val cBackground = "colorBackground"
     const val cBBackground = "colorBottomBackground"
     const val cBBackground = "colorBottomBackground"
     const val enableDeepPersonalization = "enableDeepPersonalization"
     const val enableDeepPersonalization = "enableDeepPersonalization"
@@ -297,6 +300,7 @@ object PreferKey {
     const val bookshelfRefreshingLimit = "bookshelfRefreshingLimit"
     const val bookshelfRefreshingLimit = "bookshelfRefreshingLimit"
     const val systemMediaControlCompatibilityChange = "systemMediaControlCompatibilityChange"
     const val systemMediaControlCompatibilityChange = "systemMediaControlCompatibilityChange"
     const val isPredictiveBackEnabled = "isPredictiveBackEnabled"
     const val isPredictiveBackEnabled = "isPredictiveBackEnabled"
+    const val readRecordDisplayMode = "readRecordDisplayMode"
     const val replaceSortMode = "desc"
     const val replaceSortMode = "desc"
     const val readBarStyle = "readBarStyle"
     const val readBarStyle = "readBarStyle"
     const val readMenuBgColor = "readMenuBgColor"
     const val readMenuBgColor = "readMenuBgColor"
@@ -305,9 +309,12 @@ object PreferKey {
     const val readMenuBgColorNight = "readMenuBgColorNight"
     const val readMenuBgColorNight = "readMenuBgColorNight"
     const val readMenuAccentColorNight = "readMenuAccentColorNight"
     const val readMenuAccentColorNight = "readMenuAccentColorNight"
     const val readMenuContainerColorNight = "readMenuContainerColorNight"
     const val readMenuContainerColorNight = "readMenuContainerColorNight"
+    const val readMenuTextColor = "readMenuTextColor"
+    const val readMenuTextColorNight = "readMenuTextColorNight"
     const val readMenuColorMode = "readMenuColorMode"
     const val readMenuColorMode = "readMenuColorMode"
     const val readMenuIconShowText = "readMenuIconShowText"
     const val readMenuIconShowText = "readMenuIconShowText"
     const val readMenuIconStyle = "readMenuIconStyle"
     const val readMenuIconStyle = "readMenuIconStyle"
+    const val titleBarIconStyle = "titleBarIconStyle"
     const val readMenuIconItemsPerRow = "readMenuIconItemsPerRow"
     const val readMenuIconItemsPerRow = "readMenuIconItemsPerRow"
     const val readMenuIconRowCount = "readMenuIconRowCount"
     const val readMenuIconRowCount = "readMenuIconRowCount"
     const val readMenuBottomCornerRadius = "readMenuBottomCornerRadius"
     const val readMenuBottomCornerRadius = "readMenuBottomCornerRadius"
@@ -322,6 +329,7 @@ object PreferKey {
     const val readMenuBlurRadius = "readMenuBlurRadius"
     const val readMenuBlurRadius = "readMenuBlurRadius"
     const val readMenuBlurAlpha = "readMenuBlurAlpha"
     const val readMenuBlurAlpha = "readMenuBlurAlpha"
     const val readMenuBlurColor = "readMenuBlurColor"
     const val readMenuBlurColor = "readMenuBlurColor"
+    const val readMenuBlurColorNight = "readMenuBlurColorNight"
     const val readMenuPaletteStyle = "readMenuPaletteStyle"
     const val readMenuPaletteStyle = "readMenuPaletteStyle"
     const val readMenuLensRadius = "readMenuLensRadius"
     const val readMenuLensRadius = "readMenuLensRadius"
     const val readMenuBorderWidth = "readMenuBorderWidth"
     const val readMenuBorderWidth = "readMenuBorderWidth"

+ 33 - 16
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -113,6 +113,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         where type & ${BookType.text} > 0
         where type & ${BookType.text} > 0
@@ -154,6 +155,7 @@ interface BookDao {
         canUpdate,
         canUpdate,
         ifnull(customIntro, intro) as intro,
         ifnull(customIntro, intro) as intro,
         kind,
         kind,
+        customTag,
         wordCount
         wordCount
     FROM books
     FROM books
     WHERE $PUBLIC_BOOK_FILTER
     WHERE $PUBLIC_BOOK_FILTER
@@ -189,6 +191,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.audio} > 0
         WHERE type & ${BookType.audio} > 0
@@ -224,6 +227,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE type & ${BookType.local} > 0
         WHERE type & ${BookType.local} > 0
@@ -264,6 +268,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
         where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
@@ -305,6 +310,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         where type & ${BookType.local} > 0
         where type & ${BookType.local} > 0
@@ -341,6 +347,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE (`group` & :group) > 0
         WHERE (`group` & :group) > 0
@@ -378,9 +385,11 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
-        WHERE (name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%')
+        WHERE (name like '%'||:key||'%' or author like '%'||:key||'%' or originName like '%'||:key||'%'
+            or kind like '%'||:key||'%' or customTag like '%'||:key||'%')
         AND $PUBLIC_BOOK_FILTER
         AND $PUBLIC_BOOK_FILTER
         """
         """
     )
     )
@@ -413,6 +422,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         where type & ${BookType.updateError} > 0 
         where type & ${BookType.updateError} > 0 
@@ -449,6 +459,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE durChapterIndex = 0 AND durChapterPos = 0
         WHERE durChapterIndex = 0 AND durChapterPos = 0
@@ -484,6 +495,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
@@ -521,6 +533,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
@@ -558,6 +571,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
@@ -593,6 +607,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
@@ -628,6 +643,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE type & ${BookType.image} > 0
         WHERE type & ${BookType.image} > 0
@@ -663,6 +679,7 @@ interface BookDao {
             canUpdate,
             canUpdate,
             ifnull(customIntro, intro) as intro,
             ifnull(customIntro, intro) as intro,
             kind,
             kind,
+            customTag,
             wordCount
             wordCount
         FROM books 
         FROM books 
         WHERE type & ${BookType.text} > 0
         WHERE type & ${BookType.text} > 0
@@ -875,7 +892,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE $PUBLIC_BOOK_FILTER
         WHERE $PUBLIC_BOOK_FILTER
         ORDER BY durChapterTime DESC
         ORDER BY durChapterTime DESC
@@ -891,7 +908,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
         WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -910,7 +927,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.local} > 0
         WHERE type & ${BookType.local} > 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -927,7 +944,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.audio} > 0
         WHERE type & ${BookType.audio} > 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -944,7 +961,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
         WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -962,7 +979,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.local} > 0
         WHERE type & ${BookType.local} > 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -980,7 +997,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.image} > 0
         WHERE type & ${BookType.image} > 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -997,7 +1014,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.text} > 0
         WHERE type & ${BookType.text} > 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1014,7 +1031,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE type & ${BookType.updateError} > 0
         WHERE type & ${BookType.updateError} > 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1031,7 +1048,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE durChapterIndex = 0 AND durChapterPos = 0
         WHERE durChapterIndex = 0 AND durChapterPos = 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1048,7 +1065,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1065,7 +1082,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1082,7 +1099,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1099,7 +1116,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
             AND $PUBLIC_BOOK_FILTER
             AND $PUBLIC_BOOK_FILTER
@@ -1116,7 +1133,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         FROM books
         WHERE (`group` & :groupId) > 0
         WHERE (`group` & :groupId) > 0
             AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER)
             AND ((SELECT isPrivate FROM book_groups WHERE groupId = :groupId) = 1 OR $PUBLIC_BOOK_FILTER)

+ 1 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -33,6 +33,7 @@ object LocalPreferencesKeys {
     val ENABLE_READ_RECORD = booleanPreferencesKey("enableReadRecord")
     val ENABLE_READ_RECORD = booleanPreferencesKey("enableReadRecord")
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
+    val READ_RECORD_DISPLAY_MODE = stringPreferencesKey("readRecordDisplayMode")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
     val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")
     val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_cover_album_id")
     val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id")
     val SELECTED_LIGHT_COVER_ALBUM_ID = stringPreferencesKey("selected_light_cover_album_id")

+ 3 - 6
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.catch
@@ -110,14 +111,10 @@ class ReadAloudSettingsRepository(
     }
     }
 
 
     suspend fun setTtsTimer(value: Int) {
     suspend fun setTtsTimer(value: Int) {
-        val timer = value.coerceIn(0, 180)
+        val timer = PlaybackTimer.normalize(value)
         ReadConfig.ttsTimer = timer
         ReadConfig.ttsTimer = timer
     }
     }
 
 
-    suspend fun saveTtsTimer(value: Int) {
-        settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180))
-    }
-
     suspend fun setTtsFollowSys(value: Boolean) {
     suspend fun setTtsFollowSys(value: Boolean) {
         ReadConfig.ttsFollowSys = value
         ReadConfig.ttsFollowSys = value
     }
     }
@@ -154,7 +151,7 @@ class ReadAloudSettingsRepository(
             systemMediaControlCompatibilityChange =
             systemMediaControlCompatibilityChange =
                 this[Keys.SystemMediaControlCompatibilityChange] ?: true,
                 this[Keys.SystemMediaControlCompatibilityChange] ?: true,
             streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
             streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
-            ttsTimer = this[Keys.TtsTimer] ?: 0,
+            ttsTimer = PlaybackTimer.normalize(this[Keys.TtsTimer] ?: 0),
             ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
             ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
             ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
             ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
             speechAnalysisMode = this[Keys.SpeechAnalysisMode] ?: "rule",
             speechAnalysisMode = this[Keys.SpeechAnalysisMode] ?: "rule",

+ 24 - 6
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -53,7 +53,6 @@ data class ReadPreferences(
     val disableReturnKey: Boolean = false,
     val disableReturnKey: Boolean = false,
     val expandTextMenu: Boolean = false,
     val expandTextMenu: Boolean = false,
     val showSelectMenuIcon: Boolean = true,
     val showSelectMenuIcon: Boolean = true,
-    val textSelectMenuFilter: String = "",
     val showReadTitleAddition: Boolean = true,
     val showReadTitleAddition: Boolean = true,
     val autoReadSpeed: Int = 10,
     val autoReadSpeed: Int = 10,
     val prevKeys: String = "",
     val prevKeys: String = "",
@@ -81,9 +80,12 @@ data class ReadPreferences(
     val readMenuBgColorNight: Int = 0,
     val readMenuBgColorNight: Int = 0,
     val readMenuAccentColorNight: Int = 0,
     val readMenuAccentColorNight: Int = 0,
     val readMenuContainerColorNight: Int = 0,
     val readMenuContainerColorNight: Int = 0,
+    val readMenuTextColor: Int = 0,
+    val readMenuTextColorNight: Int = 0,
     val readMenuColorMode: Int = 1,
     val readMenuColorMode: Int = 1,
     val readMenuIconShowText: Boolean = true,
     val readMenuIconShowText: Boolean = true,
     val readMenuIconStyle: Int = 0,
     val readMenuIconStyle: Int = 0,
+    val titleBarIconStyle: Int = 0,
     val readMenuIconItemsPerRow: Int = 5,
     val readMenuIconItemsPerRow: Int = 5,
     val readMenuIconRowCount: Int = 1,
     val readMenuIconRowCount: Int = 1,
     val readMenuBottomCornerRadius: Int = 0,
     val readMenuBottomCornerRadius: Int = 0,
@@ -98,6 +100,7 @@ data class ReadPreferences(
     val readMenuBlurRadius: Int = 24,
     val readMenuBlurRadius: Int = 24,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurColor: Int = 0,
     val readMenuBlurColor: Int = 0,
+    val readMenuBlurColorNight: Int = 0,
     val readMenuPaletteStyle: String = "",
     val readMenuPaletteStyle: String = "",
     val readMenuLensRadius: Float = 24f,
     val readMenuLensRadius: Float = 24f,
     val readMenuBorderWidth: Int = 0,
     val readMenuBorderWidth: Int = 0,
@@ -239,9 +242,6 @@ class ReadSettingsRepository(
     suspend fun setShowSelectMenuIcon(value: Boolean) =
     suspend fun setShowSelectMenuIcon(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.showSelectMenuIcon, value)
         settingsRepository.putBoolean(PreferKey.showSelectMenuIcon, value)
 
 
-    suspend fun setTextSelectMenuFilter(value: String) =
-        settingsRepository.putString(PreferKey.textSelectMenuFilter, value)
-
     suspend fun setShowReadTitleAddition(value: Boolean) =
     suspend fun setShowReadTitleAddition(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.showReadTitleAddition, value)
         settingsRepository.putBoolean(PreferKey.showReadTitleAddition, value)
 
 
@@ -302,6 +302,12 @@ class ReadSettingsRepository(
     suspend fun setReadMenuContainerColorNight(value: Int) =
     suspend fun setReadMenuContainerColorNight(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuContainerColorNight, value)
         settingsRepository.putInt(PreferKey.readMenuContainerColorNight, value)
 
 
+    suspend fun setReadMenuTextColor(value: Int) =
+        settingsRepository.putInt(PreferKey.readMenuTextColor, value)
+
+    suspend fun setReadMenuTextColorNight(value: Int) =
+        settingsRepository.putInt(PreferKey.readMenuTextColorNight, value)
+
     suspend fun setReadMenuColorMode(value: Int) =
     suspend fun setReadMenuColorMode(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuColorMode, value.coerceIn(0, 1))
         settingsRepository.putInt(PreferKey.readMenuColorMode, value.coerceIn(0, 1))
 
 
@@ -311,6 +317,9 @@ class ReadSettingsRepository(
     suspend fun setReadMenuIconStyle(value: Int) =
     suspend fun setReadMenuIconStyle(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuIconStyle, value.coerceIn(0, 2))
         settingsRepository.putInt(PreferKey.readMenuIconStyle, value.coerceIn(0, 2))
 
 
+    suspend fun setTitleBarIconStyle(value: Int) =
+        settingsRepository.putInt(PreferKey.titleBarIconStyle, value.coerceIn(0, 2))
+
     suspend fun setReadMenuIconItemsPerRow(value: Int) =
     suspend fun setReadMenuIconItemsPerRow(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuIconItemsPerRow, value.coerceIn(2, 8))
         settingsRepository.putInt(PreferKey.readMenuIconItemsPerRow, value.coerceIn(2, 8))
 
 
@@ -353,6 +362,9 @@ class ReadSettingsRepository(
     suspend fun setReadMenuBlurColor(value: Int) =
     suspend fun setReadMenuBlurColor(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuBlurColor, value)
         settingsRepository.putInt(PreferKey.readMenuBlurColor, value)
 
 
+    suspend fun setReadMenuBlurColorNight(value: Int) =
+        settingsRepository.putInt(PreferKey.readMenuBlurColorNight, value)
+
     suspend fun setReadMenuPaletteStyle(value: String) =
     suspend fun setReadMenuPaletteStyle(value: String) =
         settingsRepository.putString(PreferKey.readMenuPaletteStyle, value)
         settingsRepository.putString(PreferKey.readMenuPaletteStyle, value)
 
 
@@ -434,7 +446,6 @@ class ReadSettingsRepository(
             disableReturnKey = this[Keys.DisableReturnKey] ?: false,
             disableReturnKey = this[Keys.DisableReturnKey] ?: false,
             expandTextMenu = this[Keys.ExpandTextMenu] ?: false,
             expandTextMenu = this[Keys.ExpandTextMenu] ?: false,
             showSelectMenuIcon = this[Keys.ShowSelectMenuIcon] ?: true,
             showSelectMenuIcon = this[Keys.ShowSelectMenuIcon] ?: true,
-            textSelectMenuFilter = this[Keys.TextSelectMenuFilter] ?: "",
             showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true,
             showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true,
             autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10,
             autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10,
             prevKeys = this[Keys.PrevKeys] ?: "",
             prevKeys = this[Keys.PrevKeys] ?: "",
@@ -462,9 +473,12 @@ class ReadSettingsRepository(
             readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0,
             readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0,
             readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0,
             readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0,
             readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0,
             readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0,
+            readMenuTextColor = this[Keys.ReadMenuTextColor] ?: 0,
+            readMenuTextColorNight = this[Keys.ReadMenuTextColorNight] ?: 0,
             readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1,
             readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1,
             readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true,
             readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true,
             readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0,
             readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0,
+            titleBarIconStyle = this[Keys.TitleBarIconStyle] ?: 0,
             readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5,
             readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5,
             readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1,
             readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1,
             readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0,
             readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0,
@@ -483,6 +497,7 @@ class ReadSettingsRepository(
             readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24,
             readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24,
             readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60,
             readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60,
             readMenuBlurColor = this[Keys.ReadMenuBlurColor] ?: 0,
             readMenuBlurColor = this[Keys.ReadMenuBlurColor] ?: 0,
+            readMenuBlurColorNight = this[Keys.ReadMenuBlurColorNight] ?: 0,
             readMenuPaletteStyle = this[Keys.ReadMenuPaletteStyle] ?: "",
             readMenuPaletteStyle = this[Keys.ReadMenuPaletteStyle] ?: "",
             readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f,
             readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f,
             readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0,
             readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0,
@@ -535,7 +550,6 @@ class ReadSettingsRepository(
         val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey)
         val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey)
         val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
         val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
         val ShowSelectMenuIcon = booleanPreferencesKey(PreferKey.showSelectMenuIcon)
         val ShowSelectMenuIcon = booleanPreferencesKey(PreferKey.showSelectMenuIcon)
-        val TextSelectMenuFilter = stringPreferencesKey(PreferKey.textSelectMenuFilter)
         val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
         val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
         val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
         val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
         val PrevKeys = stringPreferencesKey(PreferKey.prevKeys)
         val PrevKeys = stringPreferencesKey(PreferKey.prevKeys)
@@ -563,9 +577,12 @@ class ReadSettingsRepository(
         val ReadMenuBgColorNight = intPreferencesKey(PreferKey.readMenuBgColorNight)
         val ReadMenuBgColorNight = intPreferencesKey(PreferKey.readMenuBgColorNight)
         val ReadMenuAccentColorNight = intPreferencesKey(PreferKey.readMenuAccentColorNight)
         val ReadMenuAccentColorNight = intPreferencesKey(PreferKey.readMenuAccentColorNight)
         val ReadMenuContainerColorNight = intPreferencesKey(PreferKey.readMenuContainerColorNight)
         val ReadMenuContainerColorNight = intPreferencesKey(PreferKey.readMenuContainerColorNight)
+        val ReadMenuTextColor = intPreferencesKey(PreferKey.readMenuTextColor)
+        val ReadMenuTextColorNight = intPreferencesKey(PreferKey.readMenuTextColorNight)
         val ReadMenuColorMode = intPreferencesKey(PreferKey.readMenuColorMode)
         val ReadMenuColorMode = intPreferencesKey(PreferKey.readMenuColorMode)
         val ReadMenuIconShowText = booleanPreferencesKey(PreferKey.readMenuIconShowText)
         val ReadMenuIconShowText = booleanPreferencesKey(PreferKey.readMenuIconShowText)
         val ReadMenuIconStyle = intPreferencesKey(PreferKey.readMenuIconStyle)
         val ReadMenuIconStyle = intPreferencesKey(PreferKey.readMenuIconStyle)
+        val TitleBarIconStyle = intPreferencesKey(PreferKey.titleBarIconStyle)
         val ReadMenuIconItemsPerRow = intPreferencesKey(PreferKey.readMenuIconItemsPerRow)
         val ReadMenuIconItemsPerRow = intPreferencesKey(PreferKey.readMenuIconItemsPerRow)
         val ReadMenuIconRowCount = intPreferencesKey(PreferKey.readMenuIconRowCount)
         val ReadMenuIconRowCount = intPreferencesKey(PreferKey.readMenuIconRowCount)
         val ReadMenuBottomCornerRadius = intPreferencesKey(PreferKey.readMenuBottomCornerRadius)
         val ReadMenuBottomCornerRadius = intPreferencesKey(PreferKey.readMenuBottomCornerRadius)
@@ -583,6 +600,7 @@ class ReadSettingsRepository(
         val ReadMenuBlurRadius = intPreferencesKey(PreferKey.readMenuBlurRadius)
         val ReadMenuBlurRadius = intPreferencesKey(PreferKey.readMenuBlurRadius)
         val ReadMenuBlurAlpha = intPreferencesKey(PreferKey.readMenuBlurAlpha)
         val ReadMenuBlurAlpha = intPreferencesKey(PreferKey.readMenuBlurAlpha)
         val ReadMenuBlurColor = intPreferencesKey(PreferKey.readMenuBlurColor)
         val ReadMenuBlurColor = intPreferencesKey(PreferKey.readMenuBlurColor)
+        val ReadMenuBlurColorNight = intPreferencesKey(PreferKey.readMenuBlurColorNight)
         val ReadMenuPaletteStyle = stringPreferencesKey(PreferKey.readMenuPaletteStyle)
         val ReadMenuPaletteStyle = stringPreferencesKey(PreferKey.readMenuPaletteStyle)
         val ReadMenuLensRadius = floatPreferencesKey(PreferKey.readMenuLensRadius)
         val ReadMenuLensRadius = floatPreferencesKey(PreferKey.readMenuLensRadius)
         val ReadMenuBorderWidth = intPreferencesKey(PreferKey.readMenuBorderWidth)
         val ReadMenuBorderWidth = intPreferencesKey(PreferKey.readMenuBorderWidth)

+ 8 - 0
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -44,6 +44,14 @@ class SearchContentRepository {
         cacheResults(bookUrl, query, replaceEnabled, regexReplace, emptyList())
         cacheResults(bookUrl, query, replaceEnabled, regexReplace, emptyList())
     }
     }
 
 
+    fun clearSession(bookUrl: String) {
+        if (lastSearchSession?.bookUrl == bookUrl) {
+            lastSearchResults = null
+            lastQueryKey = null
+            lastSearchSession = null
+        }
+    }
+
     fun getCache(
     fun getCache(
         bookUrl: String,
         bookUrl: String,
         query: String,
         query: String,

+ 38 - 2
app/src/main/java/io/legado/app/data/repository/SettingsRepository.kt

@@ -2,9 +2,18 @@ package io.legado.app.data.repository
 
 
 import android.content.Context
 import android.content.Context
 import androidx.core.content.edit
 import androidx.core.content.edit
+import androidx.datastore.core.DataMigration
 import androidx.datastore.core.DataStore
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.SharedPreferencesMigration
 import androidx.datastore.preferences.SharedPreferencesMigration
-import androidx.datastore.preferences.core.*
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.emptyPreferences
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.longPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
 import androidx.datastore.preferences.preferencesDataStore
 import androidx.datastore.preferences.preferencesDataStore
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.utils.defaultSharedPreferences
 import io.legado.app.utils.defaultSharedPreferences
@@ -22,11 +31,38 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
             SharedPreferencesMigration(
             SharedPreferencesMigration(
                 context,
                 context,
                 "${context.packageName}_preferences"
                 "${context.packageName}_preferences"
-            )
+            ),
+            ShowBrightnessViewMigration,
         )
         )
     }
     }
 )
 )
 
 
+internal object ShowBrightnessViewMigration : DataMigration<Preferences> {
+
+    private val booleanKey = booleanPreferencesKey(PreferKey.showBrightnessView)
+    private val stringKey = stringPreferencesKey(PreferKey.showBrightnessView)
+
+    override suspend fun shouldMigrate(currentData: Preferences): Boolean =
+        currentData.asMap().entries.any { (key, value) ->
+            key.name == PreferKey.showBrightnessView && value is Boolean
+        }
+
+    override suspend fun migrate(currentData: Preferences): Preferences {
+        val oldValue = currentData.asMap().entries
+            .firstOrNull { (key, value) ->
+                key.name == PreferKey.showBrightnessView && value is Boolean
+            }
+            ?.value as? Boolean
+            ?: return currentData
+        return currentData.toMutablePreferences().apply {
+            remove(booleanKey)
+            this[stringKey] = if (oldValue) "1" else "0"
+        }
+    }
+
+    override suspend fun cleanUp() = Unit
+}
+
 /**
 /**
  * 设置仓储
  * 设置仓储
  * 以 DataStore 为唯一写入源,读取以 DataStore 为准。
  * 以 DataStore 为唯一写入源,读取以 DataStore 为准。

+ 19 - 0
app/src/main/java/io/legado/app/domain/model/PlaybackTimer.kt

@@ -0,0 +1,19 @@
+package io.legado.app.domain.model
+
+object PlaybackTimer {
+
+    const val MIN_MINUTES = 0
+    const val MAX_MINUTES = 180
+    private const val INCREMENT_MINUTES = 10
+
+    fun normalize(minutes: Int): Int = minutes.coerceIn(MIN_MINUTES, MAX_MINUTES)
+
+    fun addIncrement(minutes: Int): Int {
+        val normalized = normalize(minutes)
+        return if (normalized == MAX_MINUTES) {
+            MIN_MINUTES
+        } else {
+            (normalized + INCREMENT_MINUTES).coerceAtMost(MAX_MINUTES)
+        }
+    }
+}

+ 2 - 5
app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt

@@ -1,6 +1,5 @@
 package io.legado.app.domain.usecase
 package io.legado.app.domain.usecase
 
 
-import io.legado.app.constant.AppConst
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
@@ -49,8 +48,6 @@ sealed interface ChangeSourceSearchEvent {
 class ChangeSourceSearchUseCase(
 class ChangeSourceSearchUseCase(
     private val gateway: BookSearchGateway,
     private val gateway: BookSearchGateway,
 ) {
 ) {
-    private val threadCount = OtherConfig.threadCount
-
     @OptIn(ExperimentalCoroutinesApi::class)
     @OptIn(ExperimentalCoroutinesApi::class)
     fun search(
     fun search(
         name: String,
         name: String,
@@ -70,7 +67,7 @@ class ChangeSourceSearchUseCase(
 
 
         var processedSources = 0
         var processedSources = 0
         var resultCount = 0
         var resultCount = 0
-        val concurrency = threadCount.coerceIn(1, AppConst.MAX_THREAD)
+        val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
 
 
         bookSourceParts.asFlow()
         bookSourceParts.asFlow()
             .mapNotNull { it.getBookSource() }
             .mapNotNull { it.getBookSource() }
@@ -124,7 +121,7 @@ class ChangeSourceSearchUseCase(
     ): Flow<ChangeSourceSearchEvent> = flow {
     ): Flow<ChangeSourceSearchEvent> = flow {
         val contentProcessor = ContentProcessor.get(oldBook)
         val contentProcessor = ContentProcessor.get(oldBook)
         val totalBooks = books.size
         val totalBooks = books.size
-        val concurrency = threadCount.coerceIn(1, AppConst.MAX_THREAD)
+        val concurrency = OtherConfig.threadCount.coerceAtLeast(1)
         var processedBooks = 0
         var processedBooks = 0
         var resultCount = 0
         var resultCount = 0
 
 

+ 4 - 1
app/src/main/java/io/legado/app/domain/usecase/ExploreBooksUseCase.kt

@@ -61,7 +61,10 @@ class ExploreBooksUseCase(
     ): ExploreRequest {
     ): ExploreRequest {
         val base = gateway.getBookSource(sourceUrl)
         val base = gateway.getBookSource(sourceUrl)
             ?: throw SourceNotFound(sourceUrl)
             ?: throw SourceNotFound(sourceUrl)
-        val source = args?.let { base.copy().also { s -> s.setTemporaryVariable(it) } } ?: base
+        val source = args
+            ?.takeIf(String::isNotBlank)
+            ?.let { base.copy().also { s -> s.setTemporaryVariable(it) } }
+            ?: base
         val url = moduleUrl
         val url = moduleUrl
             ?: (if (key != null) source.searchUrl else null)
             ?: (if (key != null) source.searchUrl else null)
             ?: source.exploreUrl
             ?: source.exploreUrl

+ 30 - 0
app/src/main/java/io/legado/app/domain/usecase/SearchBooksUseCase.kt

@@ -234,12 +234,16 @@ class SearchBooksUseCase(
         private val tagsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val tagsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
+        private val bookUrlIndex = HashMap<String, SearchBookKey>()
         var resultLimitReached = false
         var resultLimitReached = false
             private set
             private set
 
 
         val count: Int
         val count: Int
             get() = equalBooks.size + tagsBooks.size + containsBooks.size + otherBooks.size
             get() = equalBooks.size + tagsBooks.size + containsBooks.size + otherBooks.size
 
 
+        private fun allBuckets(): Sequence<Map.Entry<SearchBookKey, SearchBook>> =
+            sequenceOf(equalBooks, tagsBooks, containsBooks, otherBooks).flatMap { it.entries }
+
         suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
         suspend fun merge(newBooks: List<SearchBook>): SearchBookChange {
             if (newBooks.isEmpty()) return SearchBookChange()
             if (newBooks.isEmpty()) return SearchBookChange()
 
 
@@ -248,11 +252,26 @@ class SearchBooksUseCase(
             val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
             val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
             newBooks.forEach { newBook ->
             newBooks.forEach { newBook ->
                 coroutineContext.ensureActive()
                 coroutineContext.ensureActive()
+                val existingKey = bookUrlIndex[newBook.bookUrl]
+                if (existingKey != null) {
+                    val existingEntry = allBuckets().firstOrNull { it.key == existingKey }
+                    val existingBook = existingEntry?.value
+                    if (existingBook != null) {
+                        existingBook.addOrigin(newBook.origin)
+                        upsertBooks.add(existingBook)
+                        val existingBucket = findBucket(existingKey)
+                        if (existingBucket != null) {
+                            touchedBuckets.add(existingBucket)
+                        }
+                        return@forEach
+                    }
+                }
                 val bucket = classifyBucket(newBook) ?: return@forEach
                 val bucket = classifyBucket(newBook) ?: return@forEach
                 val key = SearchBookKey(newBook.name, newBook.author)
                 val key = SearchBookKey(newBook.name, newBook.author)
                 val currentBook = bucket[key]
                 val currentBook = bucket[key]
                 if (currentBook == null) {
                 if (currentBook == null) {
                     bucket[key] = newBook
                     bucket[key] = newBook
+                    bookUrlIndex[newBook.bookUrl] = key
                     upsertBooks.add(newBook)
                     upsertBooks.add(newBook)
                 } else {
                 } else {
                     currentBook.addOrigin(newBook.origin)
                     currentBook.addOrigin(newBook.origin)
@@ -261,6 +280,7 @@ class SearchBooksUseCase(
                 touchedBuckets.add(bucket)
                 touchedBuckets.add(bucket)
                 trimSearchBooks()?.let { removed ->
                 trimSearchBooks()?.let { removed ->
                     removedBookUrls.add(removed.bookUrl)
                     removedBookUrls.add(removed.bookUrl)
+                    bookUrlIndex.remove(removed.bookUrl)
                     upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
                     upsertBooks.removeAll { it.bookUrl == removed.bookUrl }
                 }
                 }
             }
             }
@@ -294,6 +314,16 @@ class SearchBooksUseCase(
             }
             }
         }
         }
 
 
+        private fun findBucket(key: SearchBookKey): LinkedHashMap<SearchBookKey, SearchBook>? {
+            return when {
+                key in equalBooks -> equalBooks
+                key in tagsBooks -> tagsBooks
+                key in containsBooks -> containsBooks
+                key in otherBooks -> otherBooks
+                else -> null
+            }
+        }
+
         /**
         /**
          * 按 origins.size 降序重新排列桶内元素。
          * 按 origins.size 降序重新排列桶内元素。
          * 使用 sortedEntries 重建 LinkedHashMap 以保持排序后的迭代顺序。
          * 使用 sortedEntries 重建 LinkedHashMap 以保持排序后的迭代顺序。

+ 13 - 3
app/src/main/java/io/legado/app/help/book/BookExtensions.kt

@@ -15,11 +15,11 @@ import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.HighlightTagRule
 import io.legado.app.data.entities.HighlightTagRule
-import io.legado.app.ui.book.info.HighlightedTag
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.RuleBigDataHelp
 import io.legado.app.help.RuleBigDataHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.book.info.HighlightedTag
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
@@ -122,9 +122,19 @@ fun Book.contains(word: String?): Boolean {
             || originName.contains(word)
             || originName.contains(word)
             || origin.contains(word)
             || origin.contains(word)
             || kind?.contains(word) == true
             || kind?.contains(word) == true
+            || customTag?.contains(word) == true
             || intro?.contains(word) == true
             || intro?.contains(word) == true
 }
 }
 
 
+fun Book.getSourceTagList(): List<String> =
+    kind?.splitNotBlank(",", "\n").orEmpty().distinct()
+
+fun Book.getCustomTagList(): List<String> =
+    customTag?.splitNotBlank(",", "\n").orEmpty().distinct()
+
+fun Book.getDisplayTagList(): List<String> =
+    (getCustomTagList() + getSourceTagList()).distinct()
+
 /**
 /**
  * 仅在目标bookUrl未被其他书占用,或判定为同一本书时,允许迁移主键。
  * 仅在目标bookUrl未被其他书占用,或判定为同一本书时,允许迁移主键。
  */
  */
@@ -439,7 +449,7 @@ fun applyTagGroupRules(
 
 
     val updatedBooks = mutableListOf<Book>()
     val updatedBooks = mutableListOf<Book>()
     for (book in books) {
     for (book in books) {
-        val kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
+        val kinds = book.getDisplayTagList()
         var newGroupMask = 0L
         var newGroupMask = 0L
         for ((rule, regex) in compiledRules) {
         for ((rule, regex) in compiledRules) {
             if (kinds.any { regex.containsMatchIn(it) }) {
             if (kinds.any { regex.containsMatchIn(it) }) {
@@ -496,7 +506,7 @@ fun applyTagGroupRulesForBook(book: Book) {
     }
     }
 
 
     val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
     val allRuleGroupMask = groupCache.values.fold(0L) { acc, id -> acc or id }
-    val kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
+    val kinds = book.getDisplayTagList()
     var newGroupMask = 0L
     var newGroupMask = 0L
     for ((rule, regex) in compiledRules) {
     for ((rule, regex) in compiledRules) {
         if (kinds.any { regex.containsMatchIn(it) }) {
         if (kinds.any { regex.containsMatchIn(it) }) {

+ 23 - 8
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -5,6 +5,7 @@ import io.legado.app.BuildConfig
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadPreferences
+import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.ui.config.backupConfig.BackupConfig
 import io.legado.app.ui.config.backupConfig.BackupConfig
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
 import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
@@ -24,10 +25,16 @@ import io.legado.app.utils.putPrefInt
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.sysConfiguration
 import io.legado.app.utils.sysConfiguration
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.koin.java.KoinJavaComponent.get
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
 @Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
 @Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
 object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
 object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
+    private val settingsRepository: SettingsRepository by lazy {
+        get(SettingsRepository::class.java)
+    }
     val isCronet get() = DownloadCacheConfig.cronetEnable
     val isCronet get() = DownloadCacheConfig.cronetEnable
     val useAntiAlias get() = OtherConfig.antiAlias
     val useAntiAlias get() = OtherConfig.antiAlias
     val userAgent: String get() = DownloadCacheConfig.userAgent
     val userAgent: String get() = DownloadCacheConfig.userAgent
@@ -290,16 +297,20 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
         }
 
 
     var bookExportFileName: String?
     var bookExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.bookExportFileName)
+        get() = runBlocking {
+            settingsRepository.getString(PreferKey.bookExportFileName).first().ifEmpty { null }
+        }
         set(value) {
         set(value) {
-            appCtx.putPrefString(PreferKey.bookExportFileName, value)
+            runBlocking { DsSync.putString(PreferKey.bookExportFileName, value) }
         }
         }
 
 
     // 保存 自定义导出章节模式 文件名js表达式
     // 保存 自定义导出章节模式 文件名js表达式
     var episodeExportFileName: String?
     var episodeExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.episodeExportFileName, "")
+        get() = runBlocking {
+            settingsRepository.getString(PreferKey.episodeExportFileName).first()
+        }
         set(value) {
         set(value) {
-            appCtx.putPrefString(PreferKey.episodeExportFileName, value)
+            runBlocking { DsSync.putString(PreferKey.episodeExportFileName, value) }
         }
         }
 
 
     var bookImportFileName: String?
     var bookImportFileName: String?
@@ -504,12 +515,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefInt(PreferKey.batchChangeSourceDelay, value)
             appCtx.putPrefInt(PreferKey.batchChangeSourceDelay, value)
         }
         }
 
 
-    val importKeepName get() = appCtx.getPrefBoolean(PreferKey.importKeepName)
-    val importKeepGroup get() = appCtx.getPrefBoolean(PreferKey.importKeepGroup)
+    var importKeepName: Boolean
+        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepName).first() }
+        set(value) { runBlocking { DsSync.putBoolean(PreferKey.importKeepName, value) } }
+    var importKeepGroup: Boolean
+        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepGroup).first() }
+        set(value) { runBlocking { DsSync.putBoolean(PreferKey.importKeepGroup, value) } }
     var importKeepEnable: Boolean
     var importKeepEnable: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable, false)
+        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepEnable).first() }
         set(value) {
         set(value) {
-            appCtx.putPrefBoolean(PreferKey.importKeepEnable, value)
+            runBlocking { DsSync.putBoolean(PreferKey.importKeepEnable, value) }
         }
         }
 
 
     var previewImageByClick: Boolean
     var previewImageByClick: Boolean

+ 52 - 0
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -115,6 +115,12 @@ object ReadBookConfig {
     var bg: Drawable? = null
     var bg: Drawable? = null
     var bgMeanColor: Int = 0
     var bgMeanColor: Int = 0
     val textColor: Int get() = durConfig.curTextColor()
     val textColor: Int get() = durConfig.curTextColor()
+    val textColorNight: Int
+        get() = try {
+            durConfig.getTextColorNight().toColorInt()
+        } catch (_: Exception) {
+            0xFFADADAD.toInt()
+        }
     val textAccentColor: Int get() = durConfig.curTextAccentColor()
     val textAccentColor: Int get() = durConfig.curTextAccentColor()
     val textShadowColor: Int get() = durConfig.curTextShadowColor()
     val textShadowColor: Int get() = durConfig.curTextShadowColor()
     val menuColor: Int get() = readMenuAccentColor
     val menuColor: Int get() = readMenuAccentColor
@@ -211,6 +217,8 @@ object ReadBookConfig {
     var readMenuBottomBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuBottomBarLiquidGlassButtons, false)
     var readMenuBottomBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuBottomBarLiquidGlassButtons, false)
     var readMenuBorderColor by prefDelegate(PreferKey.readMenuBorderColor, 0)
     var readMenuBorderColor by prefDelegate(PreferKey.readMenuBorderColor, 0)
     var readMenuBorderColorNight by prefDelegate(PreferKey.readMenuBorderColorNight, 0)
     var readMenuBorderColorNight by prefDelegate(PreferKey.readMenuBorderColorNight, 0)
+    var readMenuTextColor by prefDelegate(PreferKey.readMenuTextColor, 0)
+    var readMenuTextColorNight by prefDelegate(PreferKey.readMenuTextColorNight, 0)
     var showTitleBarIcons by prefDelegate(PreferKey.showTitleBarIcons, true)
     var showTitleBarIcons by prefDelegate(PreferKey.showTitleBarIcons, true)
     var readSliderMode by prefDelegate(PreferKey.readSliderMode, "0")
     var readSliderMode by prefDelegate(PreferKey.readSliderMode, "0")
     var showBrightnessView by prefDelegate(PreferKey.showBrightnessView, "1")
     var showBrightnessView by prefDelegate(PreferKey.showBrightnessView, "1")
@@ -230,6 +238,7 @@ object ReadBookConfig {
 
 
     var readMenuColorMode by clampedPrefDelegate(PreferKey.readMenuColorMode, 1, 0..1)
     var readMenuColorMode by clampedPrefDelegate(PreferKey.readMenuColorMode, 1, 0..1)
     var readMenuIconStyle by clampedPrefDelegate(PreferKey.readMenuIconStyle, 0, 0..2)
     var readMenuIconStyle by clampedPrefDelegate(PreferKey.readMenuIconStyle, 0, 0..2)
+    var titleBarIconStyle by clampedPrefDelegate(PreferKey.titleBarIconStyle, 0, 0..2)
     var readMenuIconItemsPerRow by clampedPrefDelegate(PreferKey.readMenuIconItemsPerRow, 5, 2..8)
     var readMenuIconItemsPerRow by clampedPrefDelegate(PreferKey.readMenuIconItemsPerRow, 5, 2..8)
     var readMenuIconRowCount by clampedPrefDelegate(PreferKey.readMenuIconRowCount, 1, 1..2)
     var readMenuIconRowCount by clampedPrefDelegate(PreferKey.readMenuIconRowCount, 1, 1..2)
     var readMenuBottomCornerRadius by clampedPrefDelegate(PreferKey.readMenuBottomCornerRadius, 0, 0..32)
     var readMenuBottomCornerRadius by clampedPrefDelegate(PreferKey.readMenuBottomCornerRadius, 0, 0..32)
@@ -240,6 +249,7 @@ object ReadBookConfig {
     var readMenuBlurRadius by clampedPrefDelegate(PreferKey.readMenuBlurRadius, 24, 0..32)
     var readMenuBlurRadius by clampedPrefDelegate(PreferKey.readMenuBlurRadius, 24, 0..32)
     var readMenuBlurAlpha by clampedPrefDelegate(PreferKey.readMenuBlurAlpha, 60, 0..100)
     var readMenuBlurAlpha by clampedPrefDelegate(PreferKey.readMenuBlurAlpha, 60, 0..100)
     var readMenuBlurColor by prefDelegate(PreferKey.readMenuBlurColor, 0)
     var readMenuBlurColor by prefDelegate(PreferKey.readMenuBlurColor, 0)
+    var readMenuBlurColorNight by prefDelegate(PreferKey.readMenuBlurColorNight, 0)
     var readMenuPaletteStyle: String = ""
     var readMenuPaletteStyle: String = ""
     var readMenuLensRadius by clampedPrefDelegate(PreferKey.readMenuLensRadius, 24f, 0f..48f)
     var readMenuLensRadius by clampedPrefDelegate(PreferKey.readMenuLensRadius, 24f, 0f..48f)
     var readMenuBorderWidth by clampedPrefDelegate(PreferKey.readMenuBorderWidth, 0, 0..4)
     var readMenuBorderWidth by clampedPrefDelegate(PreferKey.readMenuBorderWidth, 0, 0..4)
@@ -343,6 +353,12 @@ object ReadBookConfig {
     val resolvedMenuBorderColor: Int
     val resolvedMenuBorderColor: Int
         get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor
         get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor
 
 
+    val resolvedMenuTextColor: Int
+        get() = if (ReadStyleResolver.isNightTheme()) readMenuTextColorNight else readMenuTextColor
+
+    val resolvedMenuBlurColor: Int
+        get() = if (ReadStyleResolver.isNightTheme()) readMenuBlurColorNight else readMenuBlurColor
+
 
 
     val config get() = if (shareLayout) shareConfig else durConfig
     val config get() = if (shareLayout) shareConfig else durConfig
 
 
@@ -533,6 +549,15 @@ object ReadBookConfig {
             config.titleColor = value
             config.titleColor = value
         }
         }
 
 
+    var titleColorNight: Int
+        get() = config.titleColorNight
+        set(value) {
+            config.titleColorNight = value
+        }
+
+    val resolvedTitleColor: Int
+        get() = if (ReadStyleResolver.isNightTheme()) titleColorNight else titleColor
+
     var paragraphIndent: String
     var paragraphIndent: String
         get() = config.paragraphIndent
         get() = config.paragraphIndent
         set(value) {
         set(value) {
@@ -745,12 +770,30 @@ object ReadBookConfig {
             config.tipHeaderColor = value
             config.tipHeaderColor = value
         }
         }
 
 
+    var tipHeaderColorNight: Int
+        get() = config.tipHeaderColorNight
+        set(value) {
+            config.tipHeaderColorNight = value
+        }
+
+    val resolvedTipHeaderColor: Int
+        get() = if (ReadStyleResolver.isNightTheme()) tipHeaderColorNight else tipHeaderColor
+
     var tipFooterColor: Int
     var tipFooterColor: Int
         get() = config.tipFooterColor
         get() = config.tipFooterColor
         set(value) {
         set(value) {
             config.tipFooterColor = value
             config.tipFooterColor = value
         }
         }
 
 
+    var tipFooterColorNight: Int
+        get() = config.tipFooterColorNight
+        set(value) {
+            config.tipFooterColorNight = value
+        }
+
+    val resolvedTipFooterColor: Int
+        get() = if (ReadStyleResolver.isNightTheme()) tipFooterColorNight else tipFooterColor
+
     var tipDividerColor: Int
     var tipDividerColor: Int
         get() = config.tipDividerColor
         get() = config.tipDividerColor
         set(value) {
         set(value) {
@@ -803,6 +846,7 @@ object ReadBookConfig {
             exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
             exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
             exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
             exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
             exportConfig.titleColor = shareConfig.titleColor
             exportConfig.titleColor = shareConfig.titleColor
+            exportConfig.titleColorNight = shareConfig.titleColorNight
             exportConfig.paddingBottom = shareConfig.paddingBottom
             exportConfig.paddingBottom = shareConfig.paddingBottom
             exportConfig.paddingLeft = shareConfig.paddingLeft
             exportConfig.paddingLeft = shareConfig.paddingLeft
             exportConfig.paddingRight = shareConfig.paddingRight
             exportConfig.paddingRight = shareConfig.paddingRight
@@ -824,7 +868,9 @@ object ReadBookConfig {
             exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
             exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
             exportConfig.tipFooterRight = shareConfig.tipFooterRight
             exportConfig.tipFooterRight = shareConfig.tipFooterRight
             exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
             exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
+            exportConfig.tipHeaderColorNight = shareConfig.tipHeaderColorNight
             exportConfig.tipFooterColor = shareConfig.tipFooterColor
             exportConfig.tipFooterColor = shareConfig.tipFooterColor
+            exportConfig.tipFooterColorNight = shareConfig.tipFooterColorNight
             exportConfig.headerMode = shareConfig.headerMode
             exportConfig.headerMode = shareConfig.headerMode
             // MD3专有属性
             // MD3专有属性
             exportConfig.footerMode = shareConfig.footerMode
             exportConfig.footerMode = shareConfig.footerMode
@@ -916,6 +962,7 @@ object ReadBookConfig {
         var titleTopSpacing: Int = 0,
         var titleTopSpacing: Int = 0,
         var titleBottomSpacing: Int = 0,
         var titleBottomSpacing: Int = 0,
         var titleColor: Int = 0,
         var titleColor: Int = 0,
+        var titleColorNight: Int = 0,
         var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var titleLineSpacingExtra: Int = 12,
         var titleLineSpacingExtra: Int = 12,
         var titleLineSpacingSub: Int = 12,
         var titleLineSpacingSub: Int = 12,
@@ -954,7 +1001,9 @@ object ReadBookConfig {
         var tipFooterMiddle: Int = tipNone,
         var tipFooterMiddle: Int = tipNone,
         var tipFooterRight: Int = tipPageAndTotal,
         var tipFooterRight: Int = tipPageAndTotal,
         var tipHeaderColor: Int = 0,
         var tipHeaderColor: Int = 0,
+        var tipHeaderColorNight: Int = 0,
         var tipFooterColor: Int = 0,
         var tipFooterColor: Int = 0,
+        var tipFooterColorNight: Int = 0,
         var tipDividerColor: Int = -1,
         var tipDividerColor: Int = -1,
         var headerMode: Int = 0,
         var headerMode: Int = 0,
         var footerMode: Int = 0,
         var footerMode: Int = 0,
@@ -1065,6 +1114,7 @@ object ReadBookConfig {
             "titleTopSpacing" to titleTopSpacing,
             "titleTopSpacing" to titleTopSpacing,
             "titleBottomSpacing" to titleBottomSpacing,
             "titleBottomSpacing" to titleBottomSpacing,
             "titleColor" to titleColor,
             "titleColor" to titleColor,
+            "titleColorNight" to titleColorNight,
             "paragraphIndent" to paragraphIndent,
             "paragraphIndent" to paragraphIndent,
             "paddingBottom" to paddingBottom,
             "paddingBottom" to paddingBottom,
             "paddingLeft" to paddingLeft,
             "paddingLeft" to paddingLeft,
@@ -1087,7 +1137,9 @@ object ReadBookConfig {
             "tipFooterMiddle" to tipFooterMiddle,
             "tipFooterMiddle" to tipFooterMiddle,
             "tipFooterRight" to tipFooterRight,
             "tipFooterRight" to tipFooterRight,
             "tipHeaderColor" to tipHeaderColor,
             "tipHeaderColor" to tipHeaderColor,
+            "tipHeaderColorNight" to tipHeaderColorNight,
             "tipFooterColor" to tipFooterColor,
             "tipFooterColor" to tipFooterColor,
+            "tipFooterColorNight" to tipFooterColorNight,
             "tipDividerColor" to tipDividerColor,
             "tipDividerColor" to tipDividerColor,
             "headerMode" to headerMode,
             "headerMode" to headerMode,
             "footerMode" to footerMode,
             "footerMode" to footerMode,

+ 10 - 0
app/src/main/java/io/legado/app/help/config/ThemeConfigStore.kt

@@ -49,6 +49,16 @@ object ThemeConfigStore {
         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
         postEvent(EventBus.UP_CONFIG, arrayListOf(2))
     }
     }
 
 
+    /**
+     * 切换深浅色但不发 RECREATE:Compose 界面通过 ThemeConfig.themeMode 快照状态
+     * 自动换色(保留 ToggleButton 等组件的过渡动画),setDefaultNightMode 只会重建
+     * 未在 manifest 声明 uiMode configChanges 的旧 View Activity
+     */
+    fun applyDayNightLive() {
+        initNightMode()
+        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+    }
+
     fun applyDayNightInit(context: Context) {
     fun applyDayNightInit(context: Context) {
         initNightMode()
         initNightMode()
     }
     }

+ 23 - 89
app/src/main/java/io/legado/app/help/config/ThemeImportExport.kt

@@ -7,6 +7,7 @@ import com.google.gson.GsonBuilder
 import com.google.gson.JsonObject
 import com.google.gson.JsonObject
 import com.google.gson.JsonParser
 import com.google.gson.JsonParser
 import com.google.gson.reflect.TypeToken
 import com.google.gson.reflect.TypeToken
+import io.legado.app.ui.config.bookshelfConfig.BookshelfConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.EncoderUtils
 import io.legado.app.utils.EncoderUtils
@@ -22,100 +23,11 @@ import java.io.FileOutputStream
  */
  */
 object ThemeImportExport {
 object ThemeImportExport {
 
 
-    private const val DIR_NAME = "saved_themes"
-    private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
-
     private val EXPORT_GSON = GsonBuilder()
     private val EXPORT_GSON = GsonBuilder()
         .setPrettyPrinting()
         .setPrettyPrinting()
         .disableHtmlEscaping()
         .disableHtmlEscaping()
         .create()
         .create()
 
 
-    private val _savedThemes = mutableListOf<SavedTheme>()
-    val savedThemes: List<SavedTheme> get() = _savedThemes
-
-    init {
-        loadAll()
-    }
-
-    private fun loadAll() {
-        _savedThemes.clear()
-        baseDir.mkdirs()
-        baseDir.listFiles()?.forEach { file ->
-            if (file.isFile && file.extension == "json") {
-                kotlin.runCatching {
-                    val json = file.readText()
-                    val data = parseThemeData(json)
-                        ?: error("不支持的主题配置格式")
-                    val name = file.nameWithoutExtension
-                    _savedThemes.add(SavedTheme(name = name, data = data))
-                }
-            }
-        }
-    }
-
-    fun reload() {
-        loadAll()
-    }
-
-    /**
-     * 保存当前设置为新主题
-     */
-    fun saveCurrentAsTheme(name: String): SavedTheme {
-        val data = exportFromCurrent()
-        return saveThemeData(name, data)
-    }
-
-    /**
-     * 保存指定数据为新主题
-     */
-    fun saveCurrentAsTheme(name: String, data: ThemeExportData): SavedTheme {
-        return saveThemeData(name, data)
-    }
-
-    fun uniqueSavedThemeName(name: String): String {
-        if (_savedThemes.none { it.name == name }) return name
-        var index = 2
-        while (_savedThemes.any { it.name == "$name $index" }) index++
-        return "$name $index"
-    }
-
-    private fun saveThemeData(name: String, data: ThemeExportData): SavedTheme {
-        val file = File(baseDir, "$name.json")
-        baseDir.mkdirs()
-        file.writeText(EXPORT_GSON.toJson(data))
-        val theme = SavedTheme(name = name, data = data)
-        _savedThemes.removeAll { it.name == name }
-        _savedThemes.add(theme)
-        return theme
-    }
-
-    /**
-     * 删除已保存的主题
-     */
-    fun deleteSavedTheme(theme: SavedTheme) {
-        val file = File(baseDir, "${theme.name}.json")
-        if (file.exists() && !file.delete()) {
-            error("Failed to delete saved theme: ${theme.name}")
-        }
-        _savedThemes.removeAll { it.name == theme.name }
-    }
-
-    /**
-     * 导出已保存的主题到文件
-     */
-    fun exportSavedThemeToFile(context: Context, theme: SavedTheme, uri: Uri): Boolean {
-        return try {
-            val json = EXPORT_GSON.toJson(theme.data)
-            context.contentResolver.openOutputStream(uri)?.use {
-                it.write(json.toByteArray())
-            }
-            true
-        } catch (e: Exception) {
-            e.printStackTrace()
-            false
-        }
-    }
-
     /**
     /**
      * 从当前配置创建导出数据
      * 从当前配置创建导出数据
      */
      */
@@ -153,6 +65,8 @@ object ThemeImportExport {
             bookInfoInputColor = ThemeConfig.bookInfoInputColor,
             bookInfoInputColor = ThemeConfig.bookInfoInputColor,
             bookInfoFollowCoverColor = ThemeConfig.bookInfoFollowCoverColor,
             bookInfoFollowCoverColor = ThemeConfig.bookInfoFollowCoverColor,
             bookInfoBackgroundBlur = ThemeConfig.bookInfoBackgroundBlur,
             bookInfoBackgroundBlur = ThemeConfig.bookInfoBackgroundBlur,
+            bookInfoNetworkCoverBackground = ThemeConfig.bookInfoNetworkCoverBackground,
+            bookInfoDefaultCoverBackground = ThemeConfig.bookInfoDefaultCoverBackground,
 
 
             // 容器设置
             // 容器设置
             containerOpacity = ThemeConfig.containerOpacity,
             containerOpacity = ThemeConfig.containerOpacity,
@@ -180,6 +94,10 @@ object ThemeImportExport {
             enableCustomTagColors = ThemeConfig.enableCustomTagColors,
             enableCustomTagColors = ThemeConfig.enableCustomTagColors,
             customTagColorsJson = ThemeConfig.customTagColorsJson,
             customTagColorsJson = ThemeConfig.customTagColorsJson,
 
 
+            // 书架卡片颜色
+            bookshelfCardColor = BookshelfConfig.bookshelfCardColor,
+            bookshelfCardColorDark = BookshelfConfig.bookshelfCardColorDark,
+
             // 主界面设置
             // 主界面设置
             showHome = ThemeConfig.showHome,
             showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
             showDiscovery = ThemeConfig.showDiscovery,
@@ -335,6 +253,10 @@ object ThemeImportExport {
         ThemeConfig.bookInfoInputColor = data.bookInfoInputColor
         ThemeConfig.bookInfoInputColor = data.bookInfoInputColor
         ThemeConfig.bookInfoFollowCoverColor = data.bookInfoFollowCoverColor
         ThemeConfig.bookInfoFollowCoverColor = data.bookInfoFollowCoverColor
         ThemeConfig.bookInfoBackgroundBlur = data.bookInfoBackgroundBlur
         ThemeConfig.bookInfoBackgroundBlur = data.bookInfoBackgroundBlur
+        ThemeConfig.bookInfoNetworkCoverBackground =
+            data.bookInfoNetworkCoverBackground ?: data.bookInfoBackgroundBlur
+        ThemeConfig.bookInfoDefaultCoverBackground =
+            data.bookInfoDefaultCoverBackground ?: data.bookInfoBackgroundBlur
 
 
         // 容器设置
         // 容器设置
         ThemeConfig.containerOpacity = data.containerOpacity
         ThemeConfig.containerOpacity = data.containerOpacity
@@ -362,6 +284,10 @@ object ThemeImportExport {
         ThemeConfig.enableCustomTagColors = data.enableCustomTagColors
         ThemeConfig.enableCustomTagColors = data.enableCustomTagColors
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
 
+        // 书架卡片颜色
+        BookshelfConfig.bookshelfCardColor = data.bookshelfCardColor
+        BookshelfConfig.bookshelfCardColorDark = data.bookshelfCardColorDark
+
         // 主界面设置
         // 主界面设置
         ThemeConfig.showHome = data.showHome
         ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
         ThemeConfig.showDiscovery = data.showDiscovery
@@ -552,6 +478,8 @@ object ThemeImportExport {
         }
         }
     }
     }
 
 
+    internal fun parseLegacyThemeData(json: String): ThemeExportData? = parseThemeData(json)
+
     /**
     /**
      * 兼容曾由 R8 混淆字段名导出的主题配置。
      * 兼容曾由 R8 混淆字段名导出的主题配置。
      * 该映射对应加入首页开关、但尚未加入导航顺序和日夜独立配色的旧格式。
      * 该映射对应加入首页开关、但尚未加入导航顺序和日夜独立配色的旧格式。
@@ -725,6 +653,8 @@ data class ThemeExportData(
     val bookInfoInputColor: Int = 0,
     val bookInfoInputColor: Int = 0,
     val bookInfoFollowCoverColor: Boolean = true,
     val bookInfoFollowCoverColor: Boolean = true,
     val bookInfoBackgroundBlur: String = ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_ON,
     val bookInfoBackgroundBlur: String = ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_ON,
+    val bookInfoNetworkCoverBackground: String? = null,
+    val bookInfoDefaultCoverBackground: String? = null,
 
 
     // 容器设置
     // 容器设置
     val containerOpacity: Int = 100,
     val containerOpacity: Int = 100,
@@ -752,6 +682,10 @@ data class ThemeExportData(
     val enableCustomTagColors: Boolean = false,
     val enableCustomTagColors: Boolean = false,
     val customTagColorsJson: String? = null,
     val customTagColorsJson: String? = null,
 
 
+    // 书架卡片颜色
+    val bookshelfCardColor: Int = 0,
+    val bookshelfCardColorDark: Int = 0,
+
     // 主界面设置
     // 主界面设置
     val showHome: Boolean = true,
     val showHome: Boolean = true,
     val showDiscovery: Boolean = true,
     val showDiscovery: Boolean = true,

+ 73 - 37
app/src/main/java/io/legado/app/help/config/ThemePackageManager.kt

@@ -177,16 +177,42 @@ class ThemePackageManager(
 
 
     suspend fun loadSavedThemes(): List<SavedTheme> = withContext(Dispatchers.IO) {
     suspend fun loadSavedThemes(): List<SavedTheme> = withContext(Dispatchers.IO) {
         savedThemesRoot.mkdirs()
         savedThemesRoot.mkdirs()
-        val folderThemes = savedThemesRoot
+        savedThemesRoot
             .listFiles()
             .listFiles()
             .orEmpty()
             .orEmpty()
             .filter(File::isDirectory)
             .filter(File::isDirectory)
+            .filterNot { it.name.endsWith(".tmp") }
             .mapNotNull(::readSavedThemeFolder)
             .mapNotNull(::readSavedThemeFolder)
-        ThemeImportExport.reload()
-        val folderThemeNames = folderThemes.mapTo(mutableSetOf()) { it.name }
-        folderThemes + ThemeImportExport.savedThemes.filter { it.name !in folderThemeNames }
     }
     }
 
 
+    suspend fun hasLegacySavedThemes(): Boolean = withContext(Dispatchers.IO) {
+        savedThemesRoot.listFiles().orEmpty().any { it.isFile && it.extension == "json" }
+    }
+
+    suspend fun migrateLegacySavedThemes(): LegacyThemeMigrationResult =
+        withContext(Dispatchers.IO) {
+            var migratedCount = 0
+            var failedCount = 0
+            savedThemesRoot.listFiles().orEmpty()
+                .filter { it.isFile && it.extension == "json" }
+                .forEach { file ->
+                    runCatching {
+                        val data = ThemeImportExport.parseLegacyThemeData(file.readText())
+                            ?: error("Unsupported legacy theme: ${file.name}")
+                        saveTheme(
+                            name = uniqueSavedThemeName(file.nameWithoutExtension),
+                            data = data,
+                        )
+                        check(file.delete()) { "Unable to remove migrated theme: ${file.name}" }
+                    }.onSuccess {
+                        migratedCount++
+                    }.onFailure {
+                        failedCount++
+                    }
+                }
+            LegacyThemeMigrationResult(migratedCount, failedCount)
+        }
+
     suspend fun saveTheme(
     suspend fun saveTheme(
         name: String,
         name: String,
         data: ThemeExportData? = null,
         data: ThemeExportData? = null,
@@ -210,43 +236,22 @@ class ThemePackageManager(
                 error("Failed to delete saved theme: ${theme.name}")
                 error("Failed to delete saved theme: ${theme.name}")
             }
             }
 
 
-            ThemeImportExport.deleteSavedTheme(theme)
         }
         }
     }
     }
 
 
     suspend fun applySavedTheme(theme: SavedTheme): Result<Unit> =
     suspend fun applySavedTheme(theme: SavedTheme): Result<Unit> =
         withContext(Dispatchers.IO) {
         withContext(Dispatchers.IO) {
             runSuspendCatching {
             runSuspendCatching {
-                val packageRoot = theme.packageRootPath?.let(::File)
-                val packageManifest = theme.packageManifest
-                if (packageRoot?.isDirectory == true && packageManifest != null) {
-                    applySavedThemeFolder(theme, packageRoot, packageManifest)
-                    return@runSuspendCatching
+                val packageRoot = requireNotNull(theme.packageRootPath?.let(::File)) {
+                    "Saved theme folder is missing: ${theme.name}"
                 }
                 }
-
-                val savedAlbumId = theme.data.selectedCoverAlbumId
-                    ?.takeIf { id -> coverAlbumUseCase.albums.value.any { it.id == id } }
-                val appliedAssets = ThemeImportExport.applyToThemeConfig(
-                    data = theme.data,
-                    applyEmbeddedCoverAssets = savedAlbumId == null,
-                )
-                val selectedCoverAlbumId = if (savedAlbumId != null) {
-                    coverAlbumUseCase.selectAlbum(savedAlbumId)
-                    savedAlbumId
-                } else {
-                    importLegacyCoverAlbums(
-                        albumName = theme.name,
-                        appliedAssets = appliedAssets,
-                    )
+                val packageManifest = requireNotNull(theme.packageManifest) {
+                    "Saved theme manifest is missing: ${theme.name}"
                 }
                 }
-                if (selectedCoverAlbumId != theme.data.selectedCoverAlbumId) {
-                    ThemeImportExport.saveCurrentAsTheme(
-                        name = theme.name,
-                        data = theme.data.copy(
-                            selectedCoverAlbumId = selectedCoverAlbumId,
-                        ),
-                    )
+                require(packageRoot.isDirectory) {
+                    "Saved theme folder does not exist: ${theme.name}"
                 }
                 }
+                applySavedThemeFolder(theme, packageRoot, packageManifest)
             }
             }
         }
         }
 
 
@@ -417,10 +422,16 @@ class ThemePackageManager(
             if (path != null) {
             if (path != null) {
                 val extension = sourceExtension(path, source.key)
                 val extension = sourceExtension(path, source.key)
                 val entryPath = "${source.entryBase}.$extension"
                 val entryPath = "${source.entryBase}.$extension"
-                openSource(path).use { input ->
-                    input.copyToPackageFile(root, entryPath)
+                val copied = runCatching {
+                    openSource(path).use { input ->
+                        input.copyToPackageFile(root, entryPath)
+                    }
+                }.isSuccess
+                if (copied) {
+                    result[source.key] = entryPath
+                } else if (config.assets?.get(source.legacyAssetKey).isNullOrBlank()) {
+                    error("无法读取主题资源: $path")
                 }
                 }
-                result[source.key] = entryPath
             }
             }
         }
         }
         copyEmbeddedAssetsToFolder(root, config, result)
         copyEmbeddedAssetsToFolder(root, config, result)
@@ -698,13 +709,20 @@ class ThemePackageManager(
         selectedCoverAlbumId: String?,
         selectedCoverAlbumId: String?,
     ) {
     ) {
         saveTheme(
         saveTheme(
-            name = ThemeImportExport.uniqueSavedThemeName(name),
+            name = uniqueSavedThemeName(name),
             data = ThemeImportExport.exportFromCurrent().copy(
             data = ThemeImportExport.exportFromCurrent().copy(
                 selectedCoverAlbumId = selectedCoverAlbumId,
                 selectedCoverAlbumId = selectedCoverAlbumId,
             ),
             ),
         )
         )
     }
     }
 
 
+    private fun uniqueSavedThemeName(name: String): String {
+        if (!savedThemeDir(name).exists()) return name
+        var index = 2
+        while (savedThemeDir("$name $index").exists()) index++
+        return "$name $index"
+    }
+
     private fun importedThemeName(
     private fun importedThemeName(
         uri: Uri,
         uri: Uri,
         packageName: String? = null,
         packageName: String? = null,
@@ -922,7 +940,20 @@ class ThemePackageManager(
         val key: String,
         val key: String,
         val sourcePath: String?,
         val sourcePath: String?,
         val entryBase: String,
         val entryBase: String,
-    )
+    ) {
+        val legacyAssetKey: String
+            get() = when (key) {
+                ASSET_BACKGROUND_LIGHT -> "bgImageLight"
+                ASSET_BACKGROUND_DARK -> "bgImageDark"
+                ASSET_NAV_HOME -> "navIconHome"
+                ASSET_NAV_BOOKSHELF -> "navIconBookshelf"
+                ASSET_NAV_EXPLORE -> "navIconExplore"
+                ASSET_NAV_RSS -> "navIconRss"
+                ASSET_NAV_MY -> "navIconMy"
+                ASSET_FONT -> "appFontPath"
+                else -> key
+            }
+    }
 
 
     private data class ExportedCoverData(
     private data class ExportedCoverData(
         val albums: List<ThemePackageCoverAlbum>,
         val albums: List<ThemePackageCoverAlbum>,
@@ -930,6 +961,11 @@ class ThemePackageManager(
     )
     )
 }
 }
 
 
+data class LegacyThemeMigrationResult(
+    val migratedCount: Int,
+    val failedCount: Int,
+)
+
 @Keep
 @Keep
 data class ThemePackageManifest(
 data class ThemePackageManifest(
     @SerializedName("formatVersion")
     @SerializedName("formatVersion")

+ 46 - 4
app/src/main/java/io/legado/app/help/http/BackstageWebView.kt

@@ -13,9 +13,18 @@ import android.webkit.WebSettings
 import android.webkit.WebView
 import android.webkit.WebView
 import android.webkit.WebViewClient
 import android.webkit.WebViewClient
 import io.legado.app.constant.AppConst
 import io.legado.app.constant.AppConst
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.BaseSource
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.CacheManager
+import io.legado.app.help.WebCacheManager
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.help.webView.WebJsExtensions
+import io.legado.app.help.webView.WebJsExtensions.Companion.getInjectionString
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameCache
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameJava
+import io.legado.app.help.webView.WebJsExtensions.Companion.nameSource
 import io.legado.app.utils.runOnUI
 import io.legado.app.utils.runOnUI
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Runnable
 import kotlinx.coroutines.Runnable
@@ -43,13 +52,17 @@ class BackstageWebView(
     private val overrideUrlRegex: String? = null,
     private val overrideUrlRegex: String? = null,
     private val javaScript: String? = null,
     private val javaScript: String? = null,
     private val delayTime: Long = 0,
     private val delayTime: Long = 0,
+    private val cacheFirst: Boolean = false,
+    private val timeout: Long? = null,
+    private val result: String? = null,
+    private val isRule: Boolean = false,
 ) {
 ) {
 
 
     private val mHandler = Handler(Looper.getMainLooper())
     private val mHandler = Handler(Looper.getMainLooper())
     private var callback: Callback? = null
     private var callback: Callback? = null
     private var mWebView: WebView? = null
     private var mWebView: WebView? = null
 
 
-    suspend fun getStrResponse(): StrResponse = withTimeout(60000L) {
+    suspend fun getStrResponse(): StrResponse = withTimeout(timeout ?: 60000L) {
         suspendCancellableCoroutine { block ->
         suspendCancellableCoroutine { block ->
             block.invokeOnCancellation {
             block.invokeOnCancellation {
                 runOnUI {
                 runOnUI {
@@ -88,9 +101,22 @@ class BackstageWebView(
         mWebView = webView
         mWebView = webView
         try {
         try {
             when {
             when {
-                !html.isNullOrEmpty() -> if (url.isNullOrEmpty()) {
+                !html.isNullOrEmpty() -> if (url.isNullOrEmpty() && !isRule) {
                     webView.loadData(html, "text/html", getEncoding())
                     webView.loadData(html, "text/html", getEncoding())
                 } else {
                 } else {
+                    if (isRule) {
+                        webView.addJavascriptInterface(WebCacheManager, nameCache)
+                        tag?.let { key ->
+                            appDb.bookSourceDao.getBookSource(key)?.let { source ->
+                                webView.addJavascriptInterface(source as BaseSource, nameSource)
+                                webView.addJavascriptInterface(
+                                    WebJsExtensions(source, null, webView),
+                                    nameJava,
+                                )
+                            }
+                        }
+                    }
+                    result?.let { CacheManager.put("webview_result", it) }
                     webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
                     webView.loadDataWithBaseURL(url, html, "text/html", getEncoding(), url)
                 }
                 }
 
 
@@ -114,6 +140,11 @@ class BackstageWebView(
         settings.blockNetworkImage = true
         settings.blockNetworkImage = true
         settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
         settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
         settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
         settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
+        settings.cacheMode = if (cacheFirst) {
+            WebSettings.LOAD_CACHE_ELSE_NETWORK
+        } else {
+            WebSettings.LOAD_DEFAULT
+        }
         if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) {
         if (sourceRegex.isNullOrBlank() && overrideUrlRegex.isNullOrBlank()) {
             webView.webViewClient = HtmlWebViewClient()
             webView.webViewClient = HtmlWebViewClient()
         } else {
         } else {
@@ -164,6 +195,12 @@ class BackstageWebView(
 
 
         override fun onPageFinished(view: WebView, url: String) {
         override fun onPageFinished(view: WebView, url: String) {
             setCookie(url)
             setCookie(url)
+            result?.let {
+                view.evaluateJavascript(
+                    "window.result = $nameCache.getFromMemory('webview_result')",
+                    null,
+                )
+            }
             if (runnable == null) {
             if (runnable == null) {
                 runnable = EvalJsRunnable(view, url, getJs())
                 runnable = EvalJsRunnable(view, url, getJs())
             }
             }
@@ -183,10 +220,15 @@ class BackstageWebView(
         private inner class EvalJsRunnable(
         private inner class EvalJsRunnable(
             webView: WebView,
             webView: WebView,
             private val url: String,
             private val url: String,
-            private val mJavaScript: String
+            mJavaScript: String
         ) : Runnable {
         ) : Runnable {
             var retry = 0
             var retry = 0
             private val mWebView: WeakReference<WebView> = WeakReference(webView)
             private val mWebView: WeakReference<WebView> = WeakReference(webView)
+            private val mJavaScript = if (isRule) {
+                "$getInjectionString\n$mJavaScript"
+            } else {
+                mJavaScript
+            }
             override fun run() {
             override fun run() {
                 mWebView.get()?.evaluateJavascript(mJavaScript) {
                 mWebView.get()?.evaluateJavascript(mJavaScript) {
                     handleResult(it)
                     handleResult(it)
@@ -331,4 +373,4 @@ class BackstageWebView(
         abstract fun onResult(response: StrResponse)
         abstract fun onResult(response: StrResponse)
         abstract fun onError(error: Throwable)
         abstract fun onError(error: Throwable)
     }
     }
-}
+}

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

@@ -1,5 +1,5 @@
 package io.legado.app.help.http
 package io.legado.app.help.http
 
 
 enum class RequestMethod {
 enum class RequestMethod {
-    GET, POST
-}
+    GET, POST, HEAD
+}

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

@@ -112,6 +112,8 @@ object BackupConfig {
         PreferKey.useFlexibleTopAppBar,
         PreferKey.useFlexibleTopAppBar,
         PreferKey.bookInfoFollowCoverColor,
         PreferKey.bookInfoFollowCoverColor,
         PreferKey.bookInfoBackgroundBlur,
         PreferKey.bookInfoBackgroundBlur,
+        PreferKey.bookInfoNetworkCoverBackground,
+        PreferKey.bookInfoDefaultCoverBackground,
         PreferKey.cBackground,
         PreferKey.cBackground,
         PreferKey.cBBackground,
         PreferKey.cBBackground,
         PreferKey.cNBackground,
         PreferKey.cNBackground,

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

@@ -396,6 +396,17 @@ object Restore : KoinComponent {
 
 
     private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
     private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
         val finalMap = mutableMapOf<String, Any>()
         val finalMap = mutableMapOf<String, Any>()
+        val legacyBookInfoBackground = map[PreferKey.bookInfoBackgroundBlur] as? String
+        val restoreNetworkCoverBackground =
+            legacyBookInfoBackground != null &&
+                    PreferKey.bookInfoNetworkCoverBackground !in map &&
+                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoBackgroundBlur) &&
+                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoNetworkCoverBackground)
+        val restoreDefaultCoverBackground =
+            legacyBookInfoBackground != null &&
+                    PreferKey.bookInfoDefaultCoverBackground !in map &&
+                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoBackgroundBlur) &&
+                    BackupConfig.keyIsNotIgnore(PreferKey.bookInfoDefaultCoverBackground)
         appCtx.defaultSharedPreferences.edit {
         appCtx.defaultSharedPreferences.edit {
             map.forEach { (key, value) ->
             map.forEach { (key, value) ->
                 if (BackupConfig.keyIsNotIgnore(key)) {
                 if (BackupConfig.keyIsNotIgnore(key)) {
@@ -433,6 +444,16 @@ object Restore : KoinComponent {
                     }
                     }
                 }
                 }
             }
             }
+            legacyBookInfoBackground?.let { backgroundMode ->
+                if (restoreNetworkCoverBackground) {
+                    putString(PreferKey.bookInfoNetworkCoverBackground, backgroundMode)
+                    finalMap[PreferKey.bookInfoNetworkCoverBackground] = backgroundMode
+                }
+                if (restoreDefaultCoverBackground) {
+                    putString(PreferKey.bookInfoDefaultCoverBackground, backgroundMode)
+                    finalMap[PreferKey.bookInfoDefaultCoverBackground] = backgroundMode
+                }
+            }
         }
         }
         // 同步恢复到 DataStore
         // 同步恢复到 DataStore
         settingsRepository.batchPutFromMap(finalMap)
         settingsRepository.batchPutFromMap(finalMap)

+ 33 - 15
app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt

@@ -27,7 +27,7 @@ enum class AppVariant {
 @Keep
 @Keep
 data class GithubRelease(
 data class GithubRelease(
     val assets: List<Asset>?,
     val assets: List<Asset>?,
-    val body: String,
+    val body: String?,
     @SerializedName("prerelease")
     @SerializedName("prerelease")
     val isPreRelease: Boolean,
     val isPreRelease: Boolean,
     @SerializedName("tag_name")
     @SerializedName("tag_name")
@@ -36,26 +36,44 @@ data class GithubRelease(
     @SerializedName("created_at")
     @SerializedName("created_at")
     val createdAt: String?
     val createdAt: String?
 ) {
 ) {
-    fun gitReleaseToAppReleaseInfo(): List<AppReleaseInfo> {
+    fun gitReleaseToAppReleaseInfo(
+        supportedAbis: List<String> = Build.SUPPORTED_ABIS.asList()
+    ): List<AppReleaseInfo> {
         assets ?: throw NoStackTraceException("获取新版本出错")
         assets ?: throw NoStackTraceException("获取新版本出错")
 
 
         val version = tagName
         val version = tagName
-        val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: ""
-        val abiSuffix = when {
-            abi.contains("arm64") -> "arm64-v8a"
-            abi.contains("armeabi") -> "armeabi-v7a"
-            else -> ""
-        }
+        return selectCompatibleAssets(assets, supportedAbis)
+            .map { it.assetToAppReleaseInfo(isPreRelease, body.orEmpty(), version) }
+    }
+}
+
+private val releaseAbis = listOf(
+    "arm64-v8a",
+    "armeabi-v7a",
+    "x86_64",
+    "armeabi",
+    "x86"
+)
 
 
-        return assets
-            .filter { it.isValid }
-            .filter { asset ->
-                abiSuffix.isEmpty() || asset.name.contains(abiSuffix, ignoreCase = true)
-            }
-            .map { it.assetToAppReleaseInfo(isPreRelease, body, version) }
+internal fun selectCompatibleAssets(
+    assets: List<Asset>,
+    supportedAbis: List<String>
+): List<Asset> {
+    val validAssets = assets.filter { it.isValid }
+
+    supportedAbis.forEach { supportedAbi ->
+        val matchingAssets = validAssets.filter { asset ->
+            asset.releaseAbi.equals(supportedAbi, ignoreCase = true)
+        }
+        if (matchingAssets.isNotEmpty()) return matchingAssets
     }
     }
+
+    return validAssets.filter { it.releaseAbi == null }
 }
 }
 
 
+private val Asset.releaseAbi: String?
+    get() = releaseAbis.firstOrNull { name.contains(it, ignoreCase = true) }
+
 @Keep
 @Keep
 data class Asset(
 data class Asset(
     @SerializedName("browser_download_url")
     @SerializedName("browser_download_url")
@@ -89,4 +107,4 @@ data class Asset(
             versionName = version
             versionName = version
         )
         )
     }
     }
-}
+}

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

@@ -10,10 +10,23 @@ import io.legado.app.help.http.text
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.withTimeoutOrNull
+import okhttp3.Headers
+import okhttp3.Request
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
 
 
 object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
 object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
 
 
+    private const val repoPath = "HapeLee/legado-with-MD3"
+    private const val githubApiBaseUrl = "https://api.github.com/repos/$repoPath/releases"
+    private const val updateManifestBaseUrl =
+        "https://raw.githubusercontent.com/$repoPath/update-manifests"
+    private const val manifestTimeoutMillis = 2500L
+
     private val checkVariant: AppVariant
     private val checkVariant: AppVariant
         get() = when (AppConfig.updateToVariant) {
         get() = when (AppConfig.updateToVariant) {
             "official_version" -> AppVariant.OFFICIAL
             "official_version" -> AppVariant.OFFICIAL
@@ -22,55 +35,96 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
             else -> AppConst.appInfo.appVariant
             else -> AppConst.appInfo.appVariant
         }
         }
 
 
-    private suspend fun getLatestRelease(): List<AppReleaseInfo> {
-        val url = if (checkVariant == AppVariant.OFFICIAL)
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/latest"
-        else
-            "https://api.github.com/repos/HapeLee/legado-with-MD3/releases"
-
-        val res = okHttpClient.newCallResponse { url(url) }
-        if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
-
-        val body = res.body.text()
-        if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
+    private suspend fun getLatestRelease(variant: AppVariant): List<AppReleaseInfo> {
+        val releases = getManifestReleases(variant) ?: getApiReleases(variant)
+        return releases
+            .flatMap { it.gitReleaseToAppReleaseInfo() }
+            .sortedByDescending { it.createdAt }
+    }
 
 
-        return when (checkVariant) {
-            AppVariant.BETA_RELEASE -> {
-                val releases = GSON.fromJsonArray<GithubRelease>(body)
-                    .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
+    private suspend fun getManifestReleases(variant: AppVariant): List<GithubRelease>? {
+        val releases = when (variant) {
+            AppVariant.OFFICIAL -> listOfNotNull(getManifestRelease(AppVariant.OFFICIAL))
+            AppVariant.BETA_RELEASE -> listOfNotNull(getManifestRelease(AppVariant.BETA_RELEASE))
+            AppVariant.ALL -> listOfNotNull(
+                getManifestRelease(AppVariant.OFFICIAL),
+                getManifestRelease(AppVariant.BETA_RELEASE)
+            )
+            else -> emptyList()
+        }
+        val expectedCount = if (variant == AppVariant.ALL) 2 else 1
+        return releases.takeIf { it.size == expectedCount }
+    }
 
 
-                releases.filter { it.isPreRelease }
-                    .flatMap { it.gitReleaseToAppReleaseInfo() }
-                    .sortedByDescending { it.createdAt }
+    private suspend fun getManifestRelease(variant: AppVariant): GithubRelease? {
+        val channel = when (variant) {
+            AppVariant.OFFICIAL -> "official"
+            AppVariant.BETA_RELEASE -> "beta"
+            else -> return null
+        }
+        return try {
+            withTimeoutOrNull(manifestTimeoutMillis) {
+                val response = okHttpClient.newCallResponse {
+                    url("$updateManifestBaseUrl/$channel.json")
+                }
+                response.use {
+                    if (!it.isSuccessful) return@withTimeoutOrNull null
+                    val release = GSON.fromJsonObject<GithubRelease>(it.body.text()).getOrNull()
+                        ?: return@withTimeoutOrNull null
+                    val expectedPreRelease = variant == AppVariant.BETA_RELEASE
+                    release.takeIf {
+                        it.isPreRelease == expectedPreRelease &&
+                            it.assets.orEmpty().any { asset -> asset.isValid }
+                    }
+                }
             }
             }
+        } catch (error: CancellationException) {
+            throw error
+        } catch (_: Exception) {
+            null
+        }
+    }
 
 
-            AppVariant.OFFICIAL -> {
-                val release = GSON.fromJsonObject<GithubRelease>(body)
-                    .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
-
-                release.gitReleaseToAppReleaseInfo()
-                    .sortedByDescending { it.createdAt }
+    private suspend fun getApiReleases(variant: AppVariant): List<GithubRelease> {
+        if (variant == AppVariant.ALL) {
+            val releases = parseReleaseList(getGithubApiBody(githubApiBaseUrl))
+            val latestOfficial = try {
+                GSON.fromJsonObject<GithubRelease>(
+                    getGithubApiBody("$githubApiBaseUrl/latest")
+                ).getOrThrow()
+            } catch (error: CancellationException) {
+                throw error
+            } catch (_: Exception) {
+                null
             }
             }
+            return (listOfNotNull(latestOfficial) + releases).distinctBy { it.tagName }
+        }
 
 
-            AppVariant.ALL -> {
-                val releases = GSON.fromJsonArray<GithubRelease>(body)
+        val url = if (variant == AppVariant.OFFICIAL) {
+            "$githubApiBaseUrl/latest"
+        } else {
+            githubApiBaseUrl
+        }
+        val body = getGithubApiBody(url)
+        return when (variant) {
+            AppVariant.OFFICIAL -> listOf(
+                GSON.fromJsonObject<GithubRelease>(body)
                     .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
                     .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
-
-                releases.flatMap { it.gitReleaseToAppReleaseInfo() }
-                    .sortedByDescending { it.createdAt }
-            }
-
+            )
+            AppVariant.BETA_RELEASE -> parseReleaseList(body)
+                .filter { it.isPreRelease }
             else -> emptyList()
             else -> emptyList()
         }
         }
     }
     }
 
 
-    suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
-        val url = "https://api.github.com/repos/HapeLee/legado-with-MD3/releases/tags/$tag"
-        val res = okHttpClient.newCallResponse { url(url) }
-        if (!res.isSuccessful) return null
+    private fun parseReleaseList(body: String): List<GithubRelease> {
+        return GSON.fromJsonArray<GithubRelease>(body)
+            .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
+    }
 
 
-        val body = res.body.text()
-        val release = GSON.fromJsonObject<GithubRelease>(body).getOrElse { return null }
+    suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
+        val manifestRelease = findManifestRelease(tag)
+        val release = manifestRelease ?: getApiReleaseByTag(tag) ?: return null
         val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
         val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
 
 
         return AppUpdate.UpdateInfo(
         return AppUpdate.UpdateInfo(
@@ -81,15 +135,61 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
         )
         )
     }
     }
 
 
+    private suspend fun findManifestRelease(tag: String): GithubRelease? {
+        val variants = if (tag.contains("beta", ignoreCase = true)) {
+            listOf(AppVariant.BETA_RELEASE, AppVariant.OFFICIAL)
+        } else {
+            listOf(AppVariant.OFFICIAL, AppVariant.BETA_RELEASE)
+        }
+        variants.forEach { variant ->
+            getManifestRelease(variant)?.let { release ->
+                if (release.tagName == tag) return release
+            }
+        }
+        return null
+    }
+
+    private suspend fun getApiReleaseByTag(tag: String): GithubRelease? {
+        val response = okHttpClient.newCallResponse {
+            url("$githubApiBaseUrl/tags/$tag")
+            addGithubApiHeaders()
+        }
+        response.use {
+            if (it.code == 404) return null
+            val body = it.body.text()
+            if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
+            return GSON.fromJsonObject<GithubRelease>(body).getOrNull()
+        }
+    }
+
+    private suspend fun getGithubApiBody(url: String): String {
+        val response = okHttpClient.newCallResponse {
+            url(url)
+            addGithubApiHeaders()
+        }
+        response.use {
+            val body = it.body.text()
+            if (!it.isSuccessful) throw githubApiException(it.code, it.headers, body)
+            if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
+            return body
+        }
+    }
+
+    private fun Request.Builder.addGithubApiHeaders() {
+        header("Accept", "application/vnd.github+json")
+        header("X-GitHub-Api-Version", "2026-03-10")
+    }
+
     override fun check(scope: CoroutineScope): Coroutine<AppUpdate.UpdateInfo> {
     override fun check(scope: CoroutineScope): Coroutine<AppUpdate.UpdateInfo> {
         return Coroutine.async(scope) {
         return Coroutine.async(scope) {
             val currentVersion = AppConst.appInfo.versionName
             val currentVersion = AppConst.appInfo.versionName
-            val releases = getLatestRelease()
+            val variant = checkVariant
+            val releases = getLatestRelease(variant)
 
 
-            val filtered = if (checkVariant == AppVariant.ALL) {
+            val filtered = if (variant == AppVariant.ALL) {
                 releases
                 releases
             } else {
             } else {
-                releases.filter { it.appVariant == checkVariant }
+                releases.filter { it.appVariant == variant }
             }
             }
 
 
             val latest = filtered.firstOrNull { r ->
             val latest = filtered.firstOrNull { r ->
@@ -165,4 +265,40 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
     fun String.versionCompare(other: String): Int {
     fun String.versionCompare(other: String): Int {
         return SemVer.parse(this).compareTo(SemVer.parse(other))
         return SemVer.parse(this).compareTo(SemVer.parse(other))
     }
     }
-}
+}
+
+internal fun githubApiException(
+    code: Int,
+    headers: Headers,
+    body: String,
+    zoneId: ZoneId = ZoneId.systemDefault()
+): NoStackTraceException {
+    if (code == 403 || code == 429) {
+        val retryAfter = headers["Retry-After"]?.toLongOrNull()
+        if (retryAfter != null) {
+            return NoStackTraceException("GitHub API 请求受限,请在 $retryAfter 秒后重试")
+        }
+        if (headers["X-RateLimit-Remaining"] == "0") {
+            val resetAt = formatRateLimitReset(headers["X-RateLimit-Reset"], zoneId)
+            val message = resetAt?.let { "GitHub API 请求额度已用完,请在 $it 后重试" }
+                ?: "GitHub API 请求额度已用完,请稍后重试"
+            return NoStackTraceException(message)
+        }
+        return NoStackTraceException("GitHub API 暂时限制请求,请稍后重试")
+    }
+
+    val detail = GSON.fromJsonObject<Map<String, String>>(body)
+        .getOrNull()
+        ?.get("message")
+        ?.takeIf { it.isNotBlank() }
+    return NoStackTraceException(
+        detail?.let { "获取新版本出错($code): $it" }
+            ?: "获取新版本出错($code)"
+    )
+}
+
+private fun formatRateLimitReset(value: String?, zoneId: ZoneId): String? {
+    val epochSeconds = value?.toLongOrNull() ?: return null
+    return DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
+        .format(Instant.ofEpochSecond(epochSeconds).atZone(zoneId))
+}

+ 74 - 0
app/src/main/java/io/legado/app/help/webView/WebViewDataCleaner.kt

@@ -0,0 +1,74 @@
+package io.legado.app.help.webView
+
+import android.content.Context
+import android.webkit.CookieManager
+import android.webkit.WebStorage
+import android.webkit.WebViewDatabase
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.IOException
+import kotlin.coroutines.resume
+
+object WebViewDataCleaner {
+
+    suspend fun clear(context: Context) {
+        withContext(Dispatchers.Main.immediate) {
+            clearPlatformData(context.applicationContext)
+        }
+
+        val directoriesCleared = withContext(Dispatchers.IO) {
+            clearDataDirectories(
+                webViewDirectory = File(context.applicationInfo.dataDir, "app_webview"),
+                huaweiWebViewDirectory = File(context.applicationInfo.dataDir, "app_hws_webview"),
+            )
+        }
+        if (!directoriesCleared) {
+            throw IOException("Unable to delete WebView data directories")
+        }
+    }
+
+    private suspend fun clearPlatformData(context: Context) {
+        val cookieManager = CookieManager.getInstance()
+        suspendCancellableCoroutine { continuation ->
+            cookieManager.removeAllCookies {
+                if (continuation.isActive) {
+                    continuation.resume(Unit)
+                }
+            }
+        }
+        cookieManager.flush()
+        WebStorage.getInstance().deleteAllData()
+        WebViewDatabase.getInstance(context).apply {
+            clearFormData()
+            clearHttpAuthUsernamePassword()
+        }
+    }
+}
+
+internal fun clearDataDirectories(
+    webViewDirectory: File,
+    huaweiWebViewDirectory: File,
+): Boolean {
+    val webViewCleared = webViewDirectory.clearContents()
+    val huaweiWebViewCleared = huaweiWebViewDirectory.deleteDirectory()
+    return webViewCleared && huaweiWebViewCleared
+}
+
+private fun File.clearContents(): Boolean {
+    if (!exists()) return true
+    if (!isDirectory) return false
+
+    val children = listFiles() ?: return false
+    val childrenDeleted = children.fold(true) { success, child ->
+        child.deleteRecursively() && success
+    }
+    return childrenDeleted && listFiles()?.isEmpty() == true
+}
+
+private fun File.deleteDirectory(): Boolean {
+    if (!exists()) return true
+    if (!isDirectory) return false
+    return deleteRecursively() && !exists()
+}

+ 1 - 22
app/src/main/java/io/legado/app/lib/prefs/ThemeCardPreference.kt

@@ -4,8 +4,6 @@ import android.annotation.SuppressLint
 import android.content.Context
 import android.content.Context
 import android.content.res.ColorStateList
 import android.content.res.ColorStateList
 import android.graphics.Color
 import android.graphics.Color
-import android.os.Handler
-import android.os.Looper
 import android.util.AttributeSet
 import android.util.AttributeSet
 import android.view.ContextThemeWrapper
 import android.view.ContextThemeWrapper
 import android.view.LayoutInflater
 import android.view.LayoutInflater
@@ -20,11 +18,9 @@ import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
-import io.legado.app.lib.dialogs.alert
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.getPrefString
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.restart
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 import splitties.init.appCtx
 
 
@@ -98,29 +94,12 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                             ThemeConfig.containerOpacity = 0
                             ThemeConfig.containerOpacity = 0
                         }
                         }
                     }
                     }
-                    val oldValue = currentValue
                     currentValue = value
                     currentValue = value
                     persistString(value)
                     persistString(value)
                     callChangeListener(value)
                     callChangeListener(value)
                     ThemeConfig.appTheme = value
                     ThemeConfig.appTheme = value
                     notifyDataSetChanged()
                     notifyDataSetChanged()
-                    val isDynamicSwitch = (oldValue == "12" || value == "12")
-                    Handler(Looper.getMainLooper()).postDelayed({
-                        if (isDynamicSwitch) {
-                            context.alert(context.getString(R.string.restart_required_message)) {
-                                okButton {
-                                    Handler(Looper.getMainLooper()).postDelayed({
-                                        context.restart()
-                                    }, 100)
-                                }
-                                cancelButton {
-                                    context.toastOnUi(R.string.restart_later_message)
-                                }
-                            }
-                        } else {
-                            postEvent(EventBus.RECREATE, "")
-                        }
-                    }, 100)
+                    postEvent(EventBus.RECREATE, "")
                 }
                 }
             }
             }
         }
         }

+ 6 - 4
app/src/main/java/io/legado/app/model/AudioPlay.kt

@@ -12,6 +12,7 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.getBookSource
 import io.legado.app.help.book.getBookSource
 import io.legado.app.help.book.readSimulating
 import io.legado.app.help.book.readSimulating
@@ -335,14 +336,15 @@ object AudioPlay : CoroutineScope by MainScope() {
     }
     }
 
 
     fun setTimer(minute: Int) {
     fun setTimer(minute: Int) {
+        val timer = PlaybackTimer.normalize(minute)
         if (AudioPlayService.isRun) {
         if (AudioPlayService.isRun) {
             val intent = Intent(context, AudioPlayService::class.java)
             val intent = Intent(context, AudioPlayService::class.java)
             intent.action = IntentAction.setTimer
             intent.action = IntentAction.setTimer
-            intent.putExtra("minute", minute)
+            intent.putExtra("minute", timer)
             context.startService(intent)
             context.startService(intent)
         } else {
         } else {
-            AudioPlayService.timeMinute = minute
-            postEvent(EventBus.AUDIO_DS, minute)
+            AudioPlayService.timeMinute = timer
+            postEvent(EventBus.AUDIO_DS, timer)
         }
         }
     }
     }
 
 
@@ -438,4 +440,4 @@ object AudioPlay : CoroutineScope by MainScope() {
 
 
     }
     }
 
 
-}
+}

+ 82 - 17
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -62,7 +62,7 @@ class CacheBookModel(
     private val onDownloadSet = linkedSetOf<Int>()
     private val onDownloadSet = linkedSetOf<Int>()
     private val canceledDownloadSet = hashSetOf<Int>()
     private val canceledDownloadSet = hashSetOf<Int>()
     private val pausedChapterSet = linkedSetOf<Int>()
     private val pausedChapterSet = linkedSetOf<Int>()
-    private val pendingReadRequestMap = hashMapOf<Int, Boolean>()
+    private val pendingReadRequestMap = hashMapOf<Int, PendingReadRequest>()
     private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
     private val chapterTasks = hashMapOf<Int, Coroutine<*>>()
     private val tasks = CompositeCoroutine()
     private val tasks = CompositeCoroutine()
     private val repository = CacheDownloadRepository()
     private val repository = CacheDownloadRepository()
@@ -671,7 +671,8 @@ class CacheBookModel(
         scope: CoroutineScope,
         scope: CoroutineScope,
         chapter: BookChapter,
         chapter: BookChapter,
         semaphore: Semaphore?,
         semaphore: Semaphore?,
-        resetPageOffset: Boolean = false
+        resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ): Boolean {
     ): Boolean {
         if (!markChapterDownloadStarted(chapter.index)) {
         if (!markChapterDownloadStarted(chapter.index)) {
             // Chapter is already in onDownloadSet. Check if the task is actually alive.
             // Chapter is already in onDownloadSet. Check if the task is actually alive.
@@ -686,11 +687,19 @@ class CacheBookModel(
                 }
                 }
                 notifyDownloadSetChanged()
                 notifyDownloadSetChanged()
                 if (!markChapterDownloadStarted(chapter.index)) {
                 if (!markChapterDownloadStarted(chapter.index)) {
-                    markPendingReadRequest(chapter.index, resetPageOffset)
+                    markPendingReadRequest(
+                        chapter.index,
+                        resetPageOffset,
+                        preserveReadAloudPosition,
+                    )
                     return false
                     return false
                 }
                 }
             } else {
             } else {
-                markPendingReadRequest(chapter.index, resetPageOffset)
+                markPendingReadRequest(
+                    chapter.index,
+                    resetPageOffset,
+                    preserveReadAloudPosition,
+                )
                 return true
                 return true
             }
             }
         }
         }
@@ -712,17 +721,33 @@ class CacheBookModel(
             onSuccess(chapter)
             onSuccess(chapter)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
-            downloadFinish(chapter, content, resetPageOffset)
+            downloadFinish(
+                chapter,
+                content,
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadContent(chapter, content)
             emitPendingReadContent(chapter, content)
         }.onError {
         }.onError {
             onError(chapter, it)
             onError(chapter, it)
             ReadBook.downloadFailChapters[chapter.index] =
             ReadBook.downloadFailChapters[chapter.index] =
                 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
                 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
-            downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
+            downloadFinish(
+                chapter,
+                "获取正文失败\n${it.localizedMessage}",
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadError(chapter, it)
             emitPendingReadError(chapter, it)
         }.onCancel {
         }.onCancel {
             onCancel(chapter.index, requeue = false)
             onCancel(chapter.index, requeue = false)
-            downloadFinish(chapter, "download canceled", resetPageOffset, true)
+            downloadFinish(
+                chapter,
+                "download canceled",
+                resetPageOffset,
+                canceled = true,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
         }.onFinally {
         }.onFinally {
             chapterTasks.remove(chapter.index)
             chapterTasks.remove(chapter.index)
             host.onTaskQueuesChanged(book.bookUrl)
             host.onTaskQueuesChanged(book.bookUrl)
@@ -744,35 +769,59 @@ class CacheBookModel(
     }
     }
 
 
     @Synchronized
     @Synchronized
-    private fun markPendingReadRequest(index: Int, resetPageOffset: Boolean) {
-        pendingReadRequestMap[index] = pendingReadRequestMap[index] == true || resetPageOffset
+    private fun markPendingReadRequest(
+        index: Int,
+        resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean,
+    ) {
+        pendingReadRequestMap[index] = mergePendingReadRequest(
+            previous = pendingReadRequestMap[index],
+            resetPageOffset = resetPageOffset,
+            preserveReadAloudPosition = preserveReadAloudPosition,
+        )
     }
     }
 
 
     @Synchronized
     @Synchronized
-    private fun consumePendingReadRequest(index: Int): Boolean? {
+    private fun consumePendingReadRequest(index: Int): PendingReadRequest? {
         return pendingReadRequestMap.remove(index)
         return pendingReadRequestMap.remove(index)
     }
     }
 
 
     private fun emitPendingReadContent(chapter: BookChapter, content: String) {
     private fun emitPendingReadContent(chapter: BookChapter, content: String) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, content, resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            content,
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun emitPendingReadError(chapter: BookChapter, error: Throwable) {
     private fun emitPendingReadError(chapter: BookChapter, error: Throwable) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, "获取正文失败\n${error.localizedMessage}", resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            "获取正文失败\n${error.localizedMessage}",
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun emitPendingReadCanceled(chapter: BookChapter) {
     private fun emitPendingReadCanceled(chapter: BookChapter) {
-        val resetPageOffset = consumePendingReadRequest(chapter.index) ?: return
-        downloadFinish(chapter, "download canceled", resetPageOffset)
+        val request = consumePendingReadRequest(chapter.index) ?: return
+        downloadFinish(
+            chapter,
+            "download canceled",
+            request.resetPageOffset,
+            preserveReadAloudPosition = request.preserveReadAloudPosition,
+        )
     }
     }
 
 
     private fun downloadFinish(
     private fun downloadFinish(
         chapter: BookChapter,
         chapter: BookChapter,
         content: String,
         content: String,
         resetPageOffset: Boolean = false,
         resetPageOffset: Boolean = false,
-        canceled: Boolean = false
+        canceled: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ) {
     ) {
         if (ReadBook.book?.bookUrl == book.bookUrl) {
         if (ReadBook.book?.bookUrl == book.bookUrl) {
             ReadBook.contentLoadFinish(
             ReadBook.contentLoadFinish(
@@ -781,7 +830,23 @@ class CacheBookModel(
                 content = content,
                 content = content,
                 resetPageOffset = resetPageOffset,
                 resetPageOffset = resetPageOffset,
                 canceled = canceled,
                 canceled = canceled,
+                preserveReadAloudPosition = preserveReadAloudPosition,
             )
             )
         }
         }
     }
     }
 }
 }
+
+internal data class PendingReadRequest(
+    val resetPageOffset: Boolean,
+    val preserveReadAloudPosition: Boolean,
+)
+
+internal fun mergePendingReadRequest(
+    previous: PendingReadRequest?,
+    resetPageOffset: Boolean,
+    preserveReadAloudPosition: Boolean,
+): PendingReadRequest = PendingReadRequest(
+    resetPageOffset = previous?.resetPageOffset == true || resetPageOffset,
+    preserveReadAloudPosition =
+        (previous?.preserveReadAloudPosition ?: true) && preserveReadAloudPosition,
+)

+ 10 - 1
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -10,6 +10,7 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.SelectItem
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.HttpReadAloudService
 import io.legado.app.service.HttpReadAloudService
 import io.legado.app.service.TTSReadAloudService
 import io.legado.app.service.TTSReadAloudService
@@ -183,11 +184,19 @@ object ReadAloud {
         }
         }
     }
     }
 
 
+    fun syncLayout(context: Context = appCtx) {
+        if (BaseReadAloudService.isRun) {
+            val intent = Intent(context, aloudClass)
+            intent.action = IntentAction.syncReadAloudLayout
+            context.startForegroundServiceCompat(intent)
+        }
+    }
+
     fun setTimer(context: Context, minute: Int) {
     fun setTimer(context: Context, minute: Int) {
         if (BaseReadAloudService.isRun) {
         if (BaseReadAloudService.isRun) {
             val intent = Intent(context, aloudClass)
             val intent = Intent(context, aloudClass)
             intent.action = IntentAction.setTimer
             intent.action = IntentAction.setTimer
-            intent.putExtra("minute", minute)
+            intent.putExtra("minute", PlaybackTimer.normalize(minute))
             context.startForegroundServiceCompat(intent)
             context.startForegroundServiceCompat(intent)
         }
         }
     }
     }

+ 12 - 0
app/src/main/java/io/legado/app/model/ReadAloudLayoutPolicy.kt

@@ -0,0 +1,12 @@
+package io.legado.app.model
+
+internal fun shouldRestartReadAloudAfterContentLoad(
+    preserveReadAloudPosition: Boolean,
+    serviceChapterIndex: Int,
+    loadedChapterIndex: Int,
+): Boolean = !preserveReadAloudPosition || serviceChapterIndex != loadedChapterIndex
+
+internal fun activeReadAloudProgress(
+    isPlaying: Boolean,
+    currentProgress: Int,
+): Int? = currentProgress.takeIf { isPlaying && it > 0 }

+ 146 - 61
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -94,6 +94,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private val curChapterLoadingLock = Mutex()
     private val curChapterLoadingLock = Mutex()
     private val nextChapterLoadingLock = Mutex()
     private val nextChapterLoadingLock = Mutex()
     var readStartTime: Long = System.currentTimeMillis()
     var readStartTime: Long = System.currentTimeMillis()
+    var isUiActive = false
+    val isAutoSaveSessionRunning: Boolean
+        get() = autoSaveJob != null
+
 
 
     /* 跳转进度前进度记录 */
     /* 跳转进度前进度记录 */
     var lastBookProgress: BookProgress? = null
     var lastBookProgress: BookProgress? = null
@@ -316,69 +320,88 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
         }
     }
     }
 
 
-    fun initReadTime() {
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        if (currentActiveSession != null &&
-            (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
-        ) {
-            commitReadSession()
+    fun startReadSession() {
+        synchronized(this) {
+            readStartTime = System.currentTimeMillis()
+            initReadTime()
+            startAutoSaveSession()
         }
         }
+    }
 
 
-        if (currentActiveSession == null) {
-            lastReadLength = currentReadLength
-            currentActiveSession = ReadRecordSession(
-                deviceId = "",
-                bookName = currentBookName,
-                bookAuthor = currentBookAuthor,
-                startTime = readStartTime,
-                endTime = readStartTime,
-                words = durChapterIndex.toLong()
-            )
+    fun initReadTime() {
+        synchronized(this) {
+            val currentBookName = book?.name ?: return
+            val currentBookAuthor = book?.author ?: ""
+            if (currentActiveSession != null &&
+                (currentActiveSession!!.bookName != currentBookName || currentActiveSession!!.bookAuthor != currentBookAuthor)
+            ) {
+                commitReadSession()
+            }
+
+            if (currentActiveSession == null) {
+                lastReadLength = currentReadLength
+                currentActiveSession = ReadRecordSession(
+                    deviceId = "",
+                    bookName = currentBookName,
+                    bookAuthor = currentBookAuthor,
+                    startTime = readStartTime,
+                    endTime = readStartTime,
+                    words = durChapterIndex.toLong()
+                )
+            }
         }
         }
     }
     }
 
 
     fun upReadTime() {
     fun upReadTime() {
-        val currentLength = currentReadLength
-        val currentBookName = book?.name ?: return
-        val currentBookAuthor = book?.author ?: ""
-        val endTime = System.currentTimeMillis()
-
-        if (currentActiveSession == null ||
-            currentActiveSession!!.bookName != currentBookName ||
-            currentActiveSession!!.bookAuthor != currentBookAuthor
-        ) {
-            initReadTime()
-            return
-        }
+        synchronized(this) {
+            val currentLength = currentReadLength
+            val currentBookName = book?.name ?: return
+            val currentBookAuthor = book?.author ?: ""
+            val endTime = System.currentTimeMillis()
+
+            if (currentActiveSession == null ||
+                currentActiveSession!!.bookName != currentBookName ||
+                currentActiveSession!!.bookAuthor != currentBookAuthor
+            ) {
+                initReadTime()
+                return
+            }
 
 
-        currentActiveSession = currentActiveSession!!.copy(
-            endTime = endTime,
-            words = durChapterIndex.toLong()
-        )
+            currentActiveSession = currentActiveSession!!.copy(
+                endTime = endTime,
+                words = durChapterIndex.toLong()
+            )
 
 
-        readStartTime = endTime
-        lastReadLength = currentLength
+            readStartTime = endTime
+            lastReadLength = currentLength
+        }
     }
     }
 
 
     fun startAutoSaveSession() {
     fun startAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = ioScope.launch {
-            while (isActive) {
-                delay(AUTO_SAVE_INTERVAL)
-                commitSessionInternal()
+        synchronized(this) {
+            autoSaveJob?.cancel()
+            autoSaveJob = ioScope.launch {
+                while (isActive) {
+                    delay(AUTO_SAVE_INTERVAL)
+                    commitSessionInternal()
+                }
             }
             }
         }
         }
     }
     }
 
 
     fun stopAutoSaveSession() {
     fun stopAutoSaveSession() {
-        autoSaveJob?.cancel()
-        autoSaveJob = null
+        synchronized(this) {
+            autoSaveJob?.cancel()
+            autoSaveJob = null
+        }
     }
     }
 
 
     fun commitReadSession() {
     fun commitReadSession() {
-        val sessionToCommit = currentActiveSession ?: return
-        currentActiveSession = null
+        val sessionToCommit = synchronized(this) {
+            val session = currentActiveSession
+            currentActiveSession = null
+            session
+        } ?: return
         ioScope.launch {
         ioScope.launch {
             saveSessionToDb(sessionToCommit)
             saveSessionToDb(sessionToCommit)
         }
         }
@@ -388,7 +411,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      * 内部提交逻辑(auto-save 专用):保存后立即创建新 session 保证连续记录
      * 内部提交逻辑(auto-save 专用):保存后立即创建新 session 保证连续记录
      */
      */
     private suspend fun commitSessionInternal() {
     private suspend fun commitSessionInternal() {
-        val sessionToSave = currentActiveSession ?: return
+        val sessionToSave = synchronized(this) { currentActiveSession } ?: return
         val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
         val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
         if (sessionDuration < MIN_READ_DURATION) {
         if (sessionDuration < MIN_READ_DURATION) {
             return
             return
@@ -400,11 +423,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             return
             return
         }
         }
         // 保存成功后立即创建新 session,避免 auto-save 空窗期
         // 保存成功后立即创建新 session,避免 auto-save 空窗期
-        currentActiveSession = sessionToSave.copy(
-            startTime = sessionToSave.endTime,
-            endTime = sessionToSave.endTime,
-            words = durChapterIndex.toLong()
-        )
+        synchronized(this) {
+            val current = currentActiveSession
+            if (current != null &&
+                current.bookName == sessionToSave.bookName &&
+                current.bookAuthor == sessionToSave.bookAuthor
+            ) {
+                currentActiveSession = current.copy(
+                    startTime = sessionToSave.endTime,
+                    endTime = sessionToSave.endTime,
+                    words = durChapterIndex.toLong()
+                )
+            }
+        }
     }
     }
 
 
     /**
     /**
@@ -596,15 +627,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     /**
     /**
      * 当前页面变化
      * 当前页面变化
      */
      */
-    private fun curPageChanged(pageChanged: Boolean = false) {
+    private fun curPageChanged(
+        pageChanged: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
+    ) {
         callBack?.pageChanged()
         callBack?.pageChanged()
         curTextChapter?.let {
         curTextChapter?.let {
             if (BaseReadAloudService.isRun && it.isCompleted) {
             if (BaseReadAloudService.isRun && it.isCompleted) {
-                val scrollPageAnim = pageAnim() == 3
-                if (scrollPageAnim && pageChanged) {
-                    ReadAloud.pause(appCtx)
+                if (shouldRestartReadAloudAfterContentLoad(
+                        preserveReadAloudPosition = preserveReadAloudPosition,
+                        serviceChapterIndex = BaseReadAloudService.currentChapterIndex,
+                        loadedChapterIndex = it.chapter.index,
+                    )
+                ) {
+                    if (isScroll && pageChanged) {
+                        ReadAloud.pause(appCtx)
+                    } else {
+                        readAloud(!BaseReadAloudService.pause)
+                    }
                 } else {
                 } else {
-                    readAloud(!BaseReadAloudService.pause)
+                    ReadAloud.syncLayout()
                 }
                 }
             }
             }
         }
         }
@@ -623,6 +665,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
         }
     }
     }
 
 
+    fun syncReadAloudPage(chapterIndex: Int, chapterPos: Int) {
+        if (durChapterIndex != chapterIndex || durChapterPos == chapterPos) return
+        durChapterPos = chapterPos
+        callBack?.upContent(resetPageOffset = false)
+        saveRead(pageChanged = true)
+    }
+
     /**
     /**
      * 当前页数
      * 当前页数
      */
      */
@@ -663,22 +712,39 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
      */
     fun loadContent(
     fun loadContent(
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
-        loadContent(durChapterIndex, resetPageOffset = resetPageOffset) {
+        loadContent(
+            durChapterIndex,
+            resetPageOffset = resetPageOffset,
+            preserveReadAloudPosition = preserveReadAloudPosition,
+        ) {
             success?.invoke()
             success?.invoke()
         }
         }
         loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
     }
     }
 
 
+    fun relayoutContent() {
+        loadContent(
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+    }
+
     fun loadOrUpContent(success: (() -> Unit)? = null) {
     fun loadOrUpContent(success: (() -> Unit)? = null) {
         val curChapter = curTextChapter
         val curChapter = curTextChapter
         if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
         if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
+            val preserveReadAloudPosition = BaseReadAloudService.isRun &&
+                    BaseReadAloudService.currentChapterIndex == durChapterIndex
             curTextChapter = null
             curTextChapter = null
             nextTextChapter = null
             nextTextChapter = null
             prevTextChapter = null
             prevTextChapter = null
-            loadContent(durChapterIndex) {
+            loadContent(
+                durChapterIndex,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            ) {
                 success?.invoke()
                 success?.invoke()
             }
             }
         } else {
         } else {
@@ -707,6 +773,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         index: Int,
         index: Int,
         upContent: Boolean = true,
         upContent: Boolean = true,
         resetPageOffset: Boolean = false,
         resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
         Coroutine.async {
         Coroutine.async {
@@ -736,12 +803,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         it,
                         it,
                         upContent,
                         upContent,
                         resetPageOffset,
                         resetPageOffset,
+                        preserveReadAloudPosition,
                         success = success
                         success = success
                     )
                     )
                 } ?: download(
                 } ?: download(
                     downloadScope,
                     downloadScope,
                     chapter,
                     chapter,
-                    resetPageOffset
+                    resetPageOffset,
+                    preserveReadAloudPosition = preserveReadAloudPosition,
                 )
                 )
             }
             }
         }.onError {
         }.onError {
@@ -800,7 +869,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         } else {
         } else {
             delay(1000)
             delay(1000)
             if (addLoading(index)) {
             if (addLoading(index)) {
-                download(downloadScope, chapter, false, preDownloadSemaphore)
+                download(
+                    scope = downloadScope,
+                    chapter = chapter,
+                    resetPageOffset = false,
+                    semaphore = preDownloadSemaphore,
+                )
             }
             }
         }
         }
     }
     }
@@ -812,6 +886,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         scope: CoroutineScope,
         scope: CoroutineScope,
         chapter: BookChapter,
         chapter: BookChapter,
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         semaphore: Semaphore? = null,
         semaphore: Semaphore? = null,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
@@ -819,7 +894,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val bookSource = bookSource
         val bookSource = bookSource
         if (bookSource != null) {
         if (bookSource != null) {
             val started =
             val started =
-                CacheBook.getOrCreate(bookSource, book).download(scope, chapter, semaphore)
+                CacheBook.getOrCreate(bookSource, book).download(
+                    scope = scope,
+                    chapter = chapter,
+                    semaphore = semaphore,
+                    resetPageOffset = resetPageOffset,
+                    preserveReadAloudPosition = preserveReadAloudPosition,
+                )
             if (!started) {
             if (!started) {
                 removeLoading(chapter.index)
                 removeLoading(chapter.index)
             }
             }
@@ -830,6 +911,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 chapter,
                 chapter,
                 "加载正文失败\n$msg",
                 "加载正文失败\n$msg",
                 resetPageOffset = resetPageOffset,
                 resetPageOffset = resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
                 success = success
                 success = success
             )
             )
         }
         }
@@ -896,6 +978,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         content: String,
         content: String,
         upContent: Boolean = true,
         upContent: Boolean = true,
         resetPageOffset: Boolean,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         canceled: Boolean = false,
         canceled: Boolean = false,
         success: (() -> Unit)? = null
         success: (() -> Unit)? = null
     ) {
     ) {
@@ -940,7 +1023,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         callBack?.onLayoutPageCompleted(index, page)
                         callBack?.onLayoutPageCompleted(index, page)
                     }
                     }
                     if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
                     if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
-                    curPageChanged()
+                    curPageChanged(
+                        preserveReadAloudPosition = preserveReadAloudPosition,
+                    )
                     callBack?.contentLoadFinish()
                     callBack?.contentLoadFinish()
                 }
                 }
 
 

+ 56 - 1
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt

@@ -2,10 +2,12 @@ package io.legado.app.model.analyzeRule
 
 
 import android.text.TextUtils
 import android.text.TextUtils
 import androidx.annotation.Keep
 import androidx.annotation.Keep
+import com.google.gson.internal.LinkedTreeMap
 import com.script.CompiledScript
 import com.script.CompiledScript
 import com.script.buildScriptBindings
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
 import com.script.rhino.RhinoScriptEngine
 import io.legado.app.constant.AppPattern.JS_PATTERN
 import io.legado.app.constant.AppPattern.JS_PATTERN
+import io.legado.app.constant.AppPattern.WebJS_PATTERN
 import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.BaseBook
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
@@ -15,6 +17,7 @@ import io.legado.app.data.entities.RssArticle
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
 import io.legado.app.help.JsExtensions
 import io.legado.app.help.JsExtensions
+import io.legado.app.help.http.BackstageWebView
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.http.CookieStore
 import io.legado.app.help.source.getShareScope
 import io.legado.app.help.source.getShareScope
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
@@ -22,10 +25,12 @@ import io.legado.app.model.webBook.WebBook
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSONStrict
 import io.legado.app.utils.GSONStrict
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getOrPutLimit
 import io.legado.app.utils.getOrPutLimit
 import io.legado.app.utils.isDataUrl
 import io.legado.app.utils.isDataUrl
 import io.legado.app.utils.isJson
 import io.legado.app.utils.isJson
+import io.legado.app.utils.isMainThread
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.stackTraceStr
 import io.legado.app.utils.stackTraceStr
@@ -163,6 +168,23 @@ class AnalyzeRule(
         }
         }
     }
     }
 
 
+    private fun getWebJsResult(jsStr: String, result: Any): String {
+        check(!isMainThread) { "webJs must be called on a background thread" }
+        return runBlocking {
+            BackstageWebView(
+                url = baseUrl,
+                html = content.toString(),
+                javaScript = jsStr,
+                headerMap = getSource()?.getHeaderMap(true),
+                tag = getSource()?.getKey(),
+                cacheFirst = true,
+                timeout = 10000,
+                result = GSON.toJson(result),
+                isRule = true,
+            ).getStrResponse().body.orEmpty()
+        }
+    }
+
     /**
     /**
      * 获取文本列表
      * 获取文本列表
      */
      */
@@ -203,6 +225,8 @@ class AnalyzeRule(
                         result = replaceRegex(result.toString(), sourceRule)
                         result = replaceRegex(result.toString(), sourceRule)
                     }
                     }
                 }
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]
             } else {
             } else {
                 for (sourceRule in ruleList) {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
                     putRule(sourceRule.putMap)
@@ -211,6 +235,9 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     val rule = sourceRule.rule
                     if (rule.isNotEmpty()) {
                     if (rule.isNotEmpty()) {
                         result = when (sourceRule.mode) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result).let {
+                                GSON.fromJsonArray<String>(it).getOrNull() ?: it
+                            }
                             Mode.Js -> evalJS(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
                             Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
@@ -290,6 +317,8 @@ class AnalyzeRule(
                 }?.let {
                 }?.let {
                     replaceRegex(it, sourceRule)
                     replaceRegex(it, sourceRule)
                 }
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]?.toString()
             } else {
             } else {
                 for (sourceRule in ruleList) {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
                     putRule(sourceRule.putMap)
@@ -298,6 +327,7 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     val rule = sourceRule.rule
                     if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
                     if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
                         result = when (sourceRule.mode) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
                             Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
@@ -354,6 +384,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                         rule.splitNotBlank("&&")
                     )
                     )
 
 
+                    Mode.WebJs -> GSON.fromJsonObject<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
                     Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -387,6 +421,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                         rule.splitNotBlank("&&")
                     )
                     )
 
 
+                    Mode.WebJs -> GSON.fromJsonArray<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
                     Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -515,6 +553,18 @@ class AnalyzeRule(
             start = jsMatcher.end()
             start = jsMatcher.end()
         }
         }
 
 
+        val webJsMatcher = WebJS_PATTERN.matcher(ruleStr)
+        while (webJsMatcher.find()) {
+            if (webJsMatcher.start() > start) {
+                tmp = ruleStr.substring(start, webJsMatcher.start()).trim { it <= ' ' }
+                if (tmp.isNotEmpty()) {
+                    ruleList.add(SourceRule(tmp, mMode))
+                }
+            }
+            ruleList.add(SourceRule(webJsMatcher.group(1) ?: "", Mode.WebJs))
+            start = webJsMatcher.end()
+        }
+
         if (ruleStr.length > start) {
         if (ruleStr.length > start) {
             tmp = ruleStr.substring(start).trim { it <= ' ' }
             tmp = ruleStr.substring(start).trim { it <= ' ' }
             if (tmp.isNotEmpty()) {
             if (tmp.isNotEmpty()) {
@@ -738,7 +788,7 @@ class AnalyzeRule(
     }
     }
 
 
     enum class Mode {
     enum class Mode {
-        XPath, Json, Default, Js, Regex
+        XPath, Json, Default, Js, Regex, WebJs
     }
     }
 
 
     /**
     /**
@@ -826,6 +876,10 @@ class AnalyzeRule(
      * js实现跨域访问,不能删
      * js实现跨域访问,不能删
      */
      */
     override fun ajax(url: Any): String? {
     override fun ajax(url: Any): String? {
+        return ajax(url, null)
+    }
+
+    override fun ajax(url: Any, callTimeout: Long?): String? {
         val urlStr = if (url is List<*>) {
         val urlStr = if (url is List<*>) {
             url.firstOrNull().toString()
             url.firstOrNull().toString()
         } else {
         } else {
@@ -835,6 +889,7 @@ class AnalyzeRule(
             urlStr,
             urlStr,
             source = source,
             source = source,
             ruleData = book,
             ruleData = book,
+            callTimeout = callTimeout,
             coroutineContext = coroutineContext
             coroutineContext = coroutineContext
         )
         )
         return kotlin.runCatching {
         return kotlin.runCatching {

+ 18 - 2
app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt

@@ -15,8 +15,8 @@ import io.legado.app.constant.AppConst.UA_NAME
 import io.legado.app.constant.AppPattern
 import io.legado.app.constant.AppPattern
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.BaseSource
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.CacheManager
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.JsExtensions
 import io.legado.app.help.JsExtensions
@@ -239,7 +239,11 @@ class AnalyzeUrl(
             }
             }
             urlOption?.let { option ->
             urlOption?.let { option ->
                 option.getMethod()?.let {
                 option.getMethod()?.let {
-                    if (it.equals("POST", true)) method = RequestMethod.POST
+                    method = when (it.uppercase()) {
+                        "POST" -> RequestMethod.POST
+                        "HEAD" -> RequestMethod.HEAD
+                        else -> RequestMethod.GET
+                    }
                 }
                 }
                 option.getHeaderMap()?.forEach { entry ->
                 option.getHeaderMap()?.forEach { entry ->
                     headerMap[entry.key.toString()] = entry.value.toString()
                     headerMap[entry.key.toString()] = entry.value.toString()
@@ -278,6 +282,8 @@ class AnalyzeUrl(
                     analyzeFields(it)
                     analyzeFields(it)
                 }
                 }
             }
             }
+
+            RequestMethod.HEAD -> Unit
         }
         }
     }
     }
 
 
@@ -485,6 +491,11 @@ class AnalyzeUrl(
                             }
                             }
                         }
                         }
 
 
+                        RequestMethod.HEAD -> {
+                            get(urlNoQuery, encodedQuery)
+                            head()
+                        }
+
                         else -> get(urlNoQuery, encodedQuery)
                         else -> get(urlNoQuery, encodedQuery)
                     }
                     }
                 }.let {
                 }.let {
@@ -559,6 +570,11 @@ class AnalyzeUrl(
                         }
                         }
                     }
                     }
 
 
+                    RequestMethod.HEAD -> {
+                        get(urlNoQuery, encodedQuery)
+                        head()
+                    }
+
                     else -> get(urlNoQuery, encodedQuery)
                     else -> get(urlNoQuery, encodedQuery)
                 }
                 }
             }
             }

+ 10 - 10
app/src/main/java/io/legado/app/model/webBook/BookChapterList.kt

@@ -13,7 +13,6 @@ import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.TocEmptyException
 import io.legado.app.exception.TocEmptyException
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.simulatedTotalChapterNum
 import io.legado.app.help.book.simulatedTotalChapterNum
-import io.legado.app.help.config.AppConfig
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
@@ -164,7 +163,7 @@ object BookChapterList {
             list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
             list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
                 .getDisplayTitle(replaceRules, book.getUseReplaceRule())
                 .getDisplayTitle(replaceRules, book.getUseReplaceRule())
         coroutineContext.ensureActive()
         coroutineContext.ensureActive()
-        getWordCount(list, book)
+        preserveChapterMetadata(list, book)
         return list
         return list
     }
     }
 
 
@@ -270,17 +269,18 @@ object BookChapterList {
         return Pair(chapterList, nextUrlList)
         return Pair(chapterList, nextUrlList)
     }
     }
 
 
-    private fun getWordCount(list: ArrayList<BookChapter>, book: Book) {
-        if (!AppConfig.tocCountWords) {
-            return
-        }
+    private fun preserveChapterMetadata(list: ArrayList<BookChapter>, book: Book) {
         val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
         val chapterList = appDb.bookChapterDao.getChapterList(book.bookUrl)
         if (chapterList.isNotEmpty()) {
         if (chapterList.isNotEmpty()) {
-            val map = chapterList.associateBy({ it.getFileName() }, { it.wordCount })
+            val map = chapterList.associateBy(
+                keySelector = { it.getFileName() },
+                valueTransform = { Triple(it.wordCount, it.variable, it.reviewImg) },
+            )
             for (bookChapter in list) {
             for (bookChapter in list) {
-                val wordCount = map[bookChapter.getFileName()]
-                if (wordCount != null) {
-                    bookChapter.wordCount = wordCount
+                map[bookChapter.getFileName()]?.let { (wordCount, variable, reviewImg) ->
+                    wordCount?.let { bookChapter.wordCount = it }
+                    variable?.let { bookChapter.variable = it }
+                    reviewImg?.let { bookChapter.reviewImg = it }
                 }
                 }
             }
             }
         }
         }

+ 21 - 7
app/src/main/java/io/legado/app/model/webBook/BookContent.kt

@@ -11,6 +11,7 @@ import io.legado.app.exception.ContentEmptyException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
+import io.legado.app.help.book.isOnLineTxt
 import io.legado.app.help.book.isVideo
 import io.legado.app.help.book.isVideo
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.Debug
 import io.legado.app.model.Debug
@@ -137,6 +138,26 @@ object BookContent {
                 appendContent(it)
                 appendContent(it)
             }
             }
         }
         }
+        contentRule.subContent?.takeIf { it.isNotBlank() }?.let { rule ->
+            val rawSubContent = analyzeRule.getString(rule, unescape = false).trim()
+            val subContent = if (rawSubContent.startsWith("http", ignoreCase = true)) {
+                AnalyzeUrl(
+                    mUrl = rawSubContent,
+                    source = bookSource,
+                    ruleData = book,
+                    coroutineContext = coroutineContext,
+                ).getStrResponseAwait().body.orEmpty()
+            } else {
+                rawSubContent
+            }
+            if (subContent.isNotBlank()) {
+                if (book.isOnLineTxt) {
+                    appendContent(subContent)
+                } else if (book.isAudio) {
+                    bookChapter.putVariable("lyric", subContent)
+                }
+            }
+        }
         var contentStr = contentBuilder.toString()
         var contentStr = contentBuilder.toString()
         val titleRule = contentRule.title //先正文再章节名称
         val titleRule = contentRule.title //先正文再章节名称
         if (!titleRule.isNullOrBlank()) {
         if (!titleRule.isNullOrBlank()) {
@@ -225,13 +246,6 @@ object BookContent {
                 content = content.replace(placeholder, originalContent)
                 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) {
         if (getNextPageUrl) {
             val nextUrlRule = contentRule.nextContentUrl
             val nextUrlRule = contentRule.nextContentUrl

+ 13 - 12
app/src/main/java/io/legado/app/service/AudioPlayService.kt

@@ -29,6 +29,7 @@ import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 import io.legado.app.constant.IntentAction
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -74,6 +75,9 @@ class AudioPlayService : BaseService(),
 
 
         @JvmStatic
         @JvmStatic
         var timeMinute: Int = 0
         var timeMinute: Int = 0
+            set(value) {
+                field = PlaybackTimer.normalize(value)
+            }
 
 
         var url: String = ""
         var url: String = ""
             private set
             private set
@@ -388,12 +392,7 @@ class AudioPlayService : BaseService(),
     }
     }
 
 
     private fun addTimer() {
     private fun addTimer() {
-        if (timeMinute == 180) {
-            timeMinute = 0
-        } else {
-            timeMinute += 10
-            if (timeMinute > 180) timeMinute = 180
-        }
+        timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
         doDs()
     }
     }
 
 
@@ -404,14 +403,15 @@ class AudioPlayService : BaseService(),
         postEvent(EventBus.AUDIO_DS, timeMinute)
         postEvent(EventBus.AUDIO_DS, timeMinute)
         upAudioPlayNotification()
         upAudioPlayNotification()
         dsJob?.cancel()
         dsJob?.cancel()
+        dsJob = null
+        if (timeMinute == PlaybackTimer.MIN_MINUTES) return
         dsJob = lifecycleScope.launch {
         dsJob = lifecycleScope.launch {
             while (isActive) {
             while (isActive) {
                 delay(60000)
                 delay(60000)
+                if (timeMinute == PlaybackTimer.MIN_MINUTES) break
                 if (!pause) {
                 if (!pause) {
-                    if (timeMinute >= 0) {
-                        timeMinute--
-                    }
-                    if (timeMinute == 0) {
+                    timeMinute--
+                    if (timeMinute == PlaybackTimer.MIN_MINUTES) {
                         AudioPlay.stop()
                         AudioPlay.stop()
                         postEvent(EventBus.AUDIO_DS, timeMinute)
                         postEvent(EventBus.AUDIO_DS, timeMinute)
                         break
                         break
@@ -572,7 +572,8 @@ class AudioPlayService : BaseService(),
     private fun createNotification(): NotificationCompat.Builder {
     private fun createNotification(): NotificationCompat.Builder {
         val nTitle: String = when {
         val nTitle: String = when {
             pause -> getString(R.string.audio_pause)
             pause -> getString(R.string.audio_pause)
-            timeMinute in 1..60 -> getString(R.string.playing_timer, timeMinute)
+            timeMinute > PlaybackTimer.MIN_MINUTES ->
+                getString(R.string.playing_timer, timeMinute)
             else -> getString(R.string.audio_play_t)
             else -> getString(R.string.audio_play_t)
         } + ": ${AudioPlay.book?.name}"
         } + ": ${AudioPlay.book?.name}"
 
 
@@ -684,4 +685,4 @@ class AudioPlayService : BaseService(),
         audioManager.abandonAudioFocus(this)
         audioManager.abandonAudioFocus(this)
     }
     }
 
 
-}
+}

+ 101 - 12
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -34,6 +34,7 @@ import io.legado.app.constant.IntentAction
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.MediaHelp
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
@@ -93,6 +94,18 @@ abstract class BaseReadAloudService : BaseService(),
 
 
         @JvmStatic
         @JvmStatic
         var timeMinute: Int = 0
         var timeMinute: Int = 0
+            private set(value) {
+                field = PlaybackTimer.normalize(value)
+            }
+
+        @JvmStatic
+        @Volatile
+        var currentChapterIndex: Int = -1
+            private set
+
+        @JvmStatic
+        @Volatile
+        var currentProgress: Int = 0
             private set
             private set
 
 
         fun isPlay(): Boolean {
         fun isPlay(): Boolean {
@@ -176,8 +189,8 @@ abstract class BaseReadAloudService : BaseService(),
         initPhoneStateListener()
         initPhoneStateListener()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         setTimer(ReadConfig.ttsTimer)
         setTimer(ReadConfig.ttsTimer)
-        if (ReadConfig.ttsTimer > 0) {
-            toastOnUi("朗读定时 ${ReadConfig.ttsTimer} 分钟")
+        if (timeMinute > 0) {
+            toastOnUi("朗读定时 $timeMinute 分钟")
         }
         }
         execute {
         execute {
             ImageLoader
             ImageLoader
@@ -211,6 +224,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
     override fun onDestroy() {
     override fun onDestroy() {
+        ReadBook.upReadTime()
         super.onDestroy()
         super.onDestroy()
         prepareReadAloudGeneration++
         prepareReadAloudGeneration++
         prepareReadAloudJob?.cancel()
         prepareReadAloudJob?.cancel()
@@ -221,6 +235,8 @@ abstract class BaseReadAloudService : BaseService(),
         isRun = false
         isRun = false
         pause = true
         pause = true
         sessionStore.stop()
         sessionStore.stop()
+        currentChapterIndex = -1
+        currentProgress = 0
         abandonFocus()
         abandonFocus()
         unregisterReceiver(broadcastReceiver)
         unregisterReceiver(broadcastReceiver)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
@@ -229,6 +245,10 @@ abstract class BaseReadAloudService : BaseService(),
         mediaSessionCompat.release()
         mediaSessionCompat.release()
         ReadBook.uploadProgress()
         ReadBook.uploadProgress()
         unregisterPhoneStateListener(phoneStateListener)
         unregisterPhoneStateListener(phoneStateListener)
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         upNotificationJob?.invokeOnCompletion {
         upNotificationJob?.invokeOnCompletion {
             notificationManager.cancel(NotificationId.ReadAloudService)
             notificationManager.cancel(NotificationId.ReadAloudService)
         }
         }
@@ -245,6 +265,7 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.pause -> pauseReadAloud()
             IntentAction.pause -> pauseReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
+            IntentAction.syncReadAloudLayout -> syncTextChapterLayout()
             IntentAction.prevParagraph -> prevP()
             IntentAction.prevParagraph -> prevP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.prev -> prevChapter()
             IntentAction.prev -> prevChapter()
@@ -328,6 +349,7 @@ abstract class BaseReadAloudService : BaseService(),
             nowSpeak = preparedNowSpeak
             nowSpeak = preparedNowSpeak
             readAloudNumber = preparedReadAloudNumber
             readAloudNumber = preparedReadAloudNumber
             paragraphStartPos = preparedParagraphStartPos
             paragraphStartPos = preparedParagraphStartPos
+            updateReadAloudProgressSnapshot(preparedReadAloudNumber + 1)
             if (moveToLast) toLast = false
             if (moveToLast) toLast = false
             preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
             preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
             launch(Main) {
             launch(Main) {
@@ -374,12 +396,16 @@ abstract class BaseReadAloudService : BaseService(),
         upReadAloudNotification()
         upReadAloudNotification()
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
     }
 
 
     abstract fun playStop()
     abstract fun playStop()
 
 
     @CallSuper
     @CallSuper
     open fun pauseReadAloud(abandonFocus: Boolean = true) {
     open fun pauseReadAloud(abandonFocus: Boolean = true) {
+        ReadBook.upReadTime()
         if (useWakeLock) {
         if (useWakeLock) {
             wakeLock.release()
             wakeLock.release()
             wifiLock?.release()
             wifiLock?.release()
@@ -394,6 +420,10 @@ abstract class BaseReadAloudService : BaseService(),
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         ReadBook.uploadProgress()
         ReadBook.uploadProgress()
         doDs()
         doDs()
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
     }
     }
 
 
     @SuppressLint("WakelockTimeout")
     @SuppressLint("WakelockTimeout")
@@ -410,11 +440,15 @@ abstract class BaseReadAloudService : BaseService(),
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
     }
 
 
     abstract fun upSpeechRate(reset: Boolean = false)
     abstract fun upSpeechRate(reset: Boolean = false)
 
 
     fun upTtsProgress(progress: Int) {
     fun upTtsProgress(progress: Int) {
+        ReadBook.upReadTime()
         val chapterPosition = progress.coerceAtLeast(0)
         val chapterPosition = progress.coerceAtLeast(0)
         if (hasSpeechPlaybackQueue) {
         if (hasSpeechPlaybackQueue) {
             playbackQueue.cursorAt(chapterPosition)?.let(::publishPlaybackInfo)
             playbackQueue.cursorAt(chapterPosition)?.let(::publishPlaybackInfo)
@@ -430,10 +464,52 @@ abstract class BaseReadAloudService : BaseService(),
                 )
                 )
             )
             )
         }
         }
+        updateReadAloudProgressSnapshot(progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
     }
     }
 
 
+    protected fun updateReadAloudProgressSnapshot(progress: Int) {
+        currentChapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
+        currentProgress = progress.coerceAtLeast(0)
+    }
+
+    protected fun moveToReadAloudPage(chapterPosition: Int): Boolean {
+        val chapter = textChapter ?: return false
+        val targetPageIndex = findReadAloudPageIndex(
+            currentPageIndex = pageIndex,
+            chapterPosition = chapterPosition,
+            pageCount = chapter.pageSize,
+            pageStart = chapter::getReadLength,
+        )
+        if (targetPageIndex == pageIndex) return false
+        repeat(targetPageIndex - pageIndex) {
+            pageIndex++
+            ReadBook.moveToNextPage()
+        }
+        return true
+    }
+
+    private fun syncTextChapterLayout() {
+        val latestChapter = ReadBook.curTextChapter ?: return
+        val serviceChapter = textChapter ?: return
+        if (!latestChapter.isCompleted || latestChapter.chapter.index != serviceChapter.chapter.index) {
+            return
+        }
+        val latestPageIndex = latestChapter.getPageIndexByCharIndex(
+            (currentProgress - 1).coerceAtLeast(0)
+        )
+        if (latestPageIndex < 0) return
+        textChapter = latestChapter
+        pageIndex = latestPageIndex
+        ReadBook.syncReadAloudPage(
+            chapterIndex = latestChapter.chapter.index,
+            chapterPos = latestChapter.getReadLength(latestPageIndex),
+        )
+        upTtsProgress(currentProgress)
+    }
+
     private fun prevP() {
     private fun prevP() {
+        ReadBook.upReadTime()
         if (hasSpeechPlaybackQueue) {
         if (hasSpeechPlaybackQueue) {
             val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
             val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
             playbackQueue.previous(current)?.let { previous ->
             playbackQueue.previous(current)?.let { previous ->
@@ -548,12 +624,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
     private fun addTimer() {
     private fun addTimer() {
-        if (timeMinute == 180) {
-            timeMinute = 0
-        } else {
-            timeMinute += 10
-            if (timeMinute > 180) timeMinute = 180
-        }
+        timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
         doDs()
     }
     }
 
 
@@ -566,14 +637,15 @@ abstract class BaseReadAloudService : BaseService(),
         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
         upReadAloudNotification()
         upReadAloudNotification()
         dsJob?.cancel()
         dsJob?.cancel()
+        dsJob = null
+        if (timeMinute == PlaybackTimer.MIN_MINUTES) return
         dsJob = lifecycleScope.launch {
         dsJob = lifecycleScope.launch {
             while (isActive) {
             while (isActive) {
                 delay(60000)
                 delay(60000)
+                if (timeMinute == PlaybackTimer.MIN_MINUTES) break
                 if (!pause) {
                 if (!pause) {
-                    if (timeMinute >= 0) {
-                        timeMinute--
-                    }
-                    if (timeMinute == 0) {
+                    timeMinute--
+                    if (timeMinute == PlaybackTimer.MIN_MINUTES) {
                         ReadAloud.stop(this@BaseReadAloudService)
                         ReadAloud.stop(this@BaseReadAloudService)
                         sessionStore.updateTimer(timeMinute)
                         sessionStore.updateTimer(timeMinute)
                         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
                         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
@@ -868,6 +940,7 @@ abstract class BaseReadAloudService : BaseService(),
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
 
     open fun prevChapter() {
     open fun prevChapter() {
+        ReadBook.upReadTime()
         toLast = false
         toLast = false
         resumeReadAloudInternal()
         resumeReadAloudInternal()
         ReadBook.moveToPrevChapter(true, toLast = false)
         ReadBook.moveToPrevChapter(true, toLast = false)
@@ -960,3 +1033,19 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
 }
 }
+
+internal inline fun findReadAloudPageIndex(
+    currentPageIndex: Int,
+    chapterPosition: Int,
+    pageCount: Int,
+    pageStart: (Int) -> Int,
+): Int {
+    var targetPageIndex = currentPageIndex
+    while (
+        targetPageIndex + 1 < pageCount &&
+        chapterPosition > pageStart(targetPageIndex + 1)
+    ) {
+        targetPageIndex++
+    }
+    return targetPageIndex
+}

+ 7 - 8
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -777,10 +777,10 @@ class HttpReadAloudService : BaseReadAloudService(),
 
 
     private fun upPlayPos() {
     private fun upPlayPos() {
         playIndexJob?.cancel()
         playIndexJob?.cancel()
-        val textChapter = textChapter ?: return
+        if (textChapter == null) return
         playIndexJob = lifecycleScope.launch {
         playIndexJob = lifecycleScope.launch {
-            upTtsProgress(readAloudNumber + 1)
             if (exoPlayer.duration <= 0) {
             if (exoPlayer.duration <= 0) {
+                upTtsProgress(readAloudNumber + 1)
                 return@launch
                 return@launch
             }
             }
             val speakTextLength = contentList[nowSpeak].length
             val speakTextLength = contentList[nowSpeak].length
@@ -789,13 +789,12 @@ class HttpReadAloudService : BaseReadAloudService(),
             }
             }
             val sleep = exoPlayer.duration / speakTextLength
             val sleep = exoPlayer.duration / speakTextLength
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
+            upTtsProgress(readAloudNumber + start.toInt() + 1)
             for (i in start..contentList[nowSpeak].length) {
             for (i in start..contentList[nowSpeak].length) {
-                if (pageIndex + 1 < textChapter.pageSize
-                    && readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)
-                ) {
-                    pageIndex++
-                    ReadBook.moveToNextPage()
-                    upTtsProgress(readAloudNumber + i.toInt())
+                val chapterPosition = readAloudNumber + i.toInt()
+                updateReadAloudProgressSnapshot(chapterPosition + 1)
+                if (moveToReadAloudPage(chapterPosition)) {
+                    upTtsProgress(chapterPosition + 1)
                 }
                 }
                 delay(sleep)
                 delay(sleep)
             }
             }

+ 22 - 9
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -40,6 +40,7 @@ class TTSReadAloudService : BaseReadAloudService() {
     private val ttsUtteranceListener = TTSUtteranceListener()
     private val ttsUtteranceListener = TTSUtteranceListener()
     private var speakJob: Coroutine<*>? = null
     private var speakJob: Coroutine<*>? = null
     private var utteranceStartPos = 0
     private var utteranceStartPos = 0
+    private var utteranceStartReadAloudNumber = 0
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
     private var activeEngine = ""
     private var activeEngine = ""
     private var activeVoiceName = ""
     private var activeVoiceName = ""
@@ -324,6 +325,7 @@ class TTSReadAloudService : BaseReadAloudService() {
             if (!isCurrentUtterance(s)) return
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
             utteranceStartPos = paragraphStartPos
+            utteranceStartReadAloudNumber = readAloudNumber
             textChapter?.let {
             textChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph()
                     nextParagraph()
@@ -353,17 +355,13 @@ class TTSReadAloudService : BaseReadAloudService() {
             super.onRangeStart(utteranceId, start, end, frame)
             super.onRangeStart(utteranceId, start, end, frame)
             if (!isCurrentUtterance(utteranceId)) return
             if (!isCurrentUtterance(utteranceId)) return
             paragraphStartPos = utteranceStartPos + start
             paragraphStartPos = utteranceStartPos + start
+            readAloudNumber = currentRangePosition(utteranceStartReadAloudNumber, start)
+            updateReadAloudProgressSnapshot(readAloudNumber + 1)
             val msg =
             val msg =
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
             LogUtils.d(TAG, msg)
             LogUtils.d(TAG, msg)
-            textChapter?.let {
-                if (pageIndex + 1 < it.pageSize
-                    && readAloudNumber + start > it.getReadLength(pageIndex + 1)
-                ) {
-                    pageIndex++
-                    ReadBook.moveToNextPage()
-                    upTtsProgress(readAloudNumber + start)
-                }
+            if (moveToReadAloudPage(readAloudNumber)) {
+                upTtsProgress(readAloudNumber + 1)
             }
             }
         }
         }
 
 
@@ -389,7 +387,11 @@ class TTSReadAloudService : BaseReadAloudService() {
             }
             }
             //跳过全标点段落
             //跳过全标点段落
             do {
             do {
-                readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
+                readAloudNumber = nextParagraphPosition(
+                    currentPosition = readAloudNumber,
+                    paragraphLength = contentList[nowSpeak].length,
+                    paragraphStartPosition = paragraphStartPos,
+                )
                 paragraphStartPos = 0
                 paragraphStartPos = 0
                 nowSpeak++
                 nowSpeak++
                 if (nowSpeak >= contentList.size) {
                 if (nowSpeak >= contentList.size) {
@@ -420,3 +422,14 @@ class TTSReadAloudService : BaseReadAloudService() {
     }
     }
 
 
 }
 }
+
+internal fun nextParagraphPosition(
+    currentPosition: Int,
+    paragraphLength: Int,
+    paragraphStartPosition: Int,
+): Int = currentPosition + paragraphLength + 1 - paragraphStartPosition
+
+internal fun currentRangePosition(
+    utteranceStartPosition: Int,
+    rangeStart: Int,
+): Int = utteranceStartPosition + rangeStart

+ 2 - 2
app/src/main/java/io/legado/app/ui/ai/chat/AiGeneratedMessageContent.kt

@@ -197,7 +197,7 @@ fun AiGeneratedMessageContent(
                     SmallPlainButton(
                     SmallPlainButton(
                         onClick = { onSwitchBranch(-1) },
                         onClick = { onSwitchBranch(-1) },
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
-                        contentDescription = "Previous branch",
+                        contentDescription = stringResource(R.string.previous),
                         modifier = Modifier.size(28.dp)
                         modifier = Modifier.size(28.dp)
                     )
                     )
                     AppText(
                     AppText(
@@ -208,7 +208,7 @@ fun AiGeneratedMessageContent(
                     SmallPlainButton(
                     SmallPlainButton(
                         onClick = { onSwitchBranch(1) },
                         onClick = { onSwitchBranch(1) },
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
-                        contentDescription = "Next branch",
+                        contentDescription = stringResource(R.string.next),
                         modifier = Modifier.size(28.dp)
                         modifier = Modifier.size(28.dp)
                     )
                     )
                 }
                 }

+ 1 - 0
app/src/main/java/io/legado/app/ui/animation/InteractiveHighlight.kt

@@ -37,6 +37,7 @@ class InteractiveHighlight(
     private var startPosition = Offset.Zero
     private var startPosition = Offset.Zero
     val pressProgress: Float get() = pressProgressAnimation.value
     val pressProgress: Float get() = pressProgressAnimation.value
     val offset: Offset get() = positionAnimation.value
     val offset: Offset get() = positionAnimation.value
+    val dragOffset: Offset get() = positionAnimation.value - startPosition
 
 
     @Language("AGSL")
     @Language("AGSL")
     private val shader = RuntimeShader(
     private val shader = RuntimeShader(

+ 3 - 4
app/src/main/java/io/legado/app/ui/association/ImportBookSourceDialog.kt

@@ -14,7 +14,6 @@ import io.legado.app.R
 import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.base.BaseBottomSheetDialogFragment
 import io.legado.app.base.adapter.ItemViewHolder
 import io.legado.app.base.adapter.ItemViewHolder
 import io.legado.app.base.adapter.RecyclerAdapter
 import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.DialogCustomGroupBinding
 import io.legado.app.databinding.DialogCustomGroupBinding
@@ -173,12 +172,12 @@ class ImportBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_r
 
 
             R.id.menu_keep_original_name -> {
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepName, item.isChecked)
+                AppConfig.importKeepName = item.isChecked
             }
             }
 
 
             R.id.menu_keep_group -> {
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
+                AppConfig.importKeepGroup = item.isChecked
             }
             }
 
 
             R.id.menu_keep_enable -> {
             R.id.menu_keep_enable -> {
@@ -280,4 +279,4 @@ class ImportBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_r
 
 
     }
     }
 
 
-}
+}

+ 3 - 4
app/src/main/java/io/legado/app/ui/association/ImportRssSourceDialog.kt

@@ -14,7 +14,6 @@ import io.legado.app.R
 import io.legado.app.base.BaseDialogFragment
 import io.legado.app.base.BaseDialogFragment
 import io.legado.app.base.adapter.ItemViewHolder
 import io.legado.app.base.adapter.ItemViewHolder
 import io.legado.app.base.adapter.RecyclerAdapter
 import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 import io.legado.app.data.entities.RssSource
 import io.legado.app.databinding.DialogCustomGroupBinding
 import io.legado.app.databinding.DialogCustomGroupBinding
@@ -153,12 +152,12 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
             R.id.menu_new_group -> alertCustomGroup(item)
             R.id.menu_new_group -> alertCustomGroup(item)
             R.id.menu_keep_original_name -> {
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepName, item.isChecked)
+                AppConfig.importKeepName = item.isChecked
             }
             }
 
 
             R.id.menu_keep_group -> {
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
+                AppConfig.importKeepGroup = item.isChecked
             }
             }
 
 
             R.id.menu_keep_enable -> {
             R.id.menu_keep_enable -> {
@@ -259,4 +258,4 @@ class ImportRssSourceDialog() : BaseDialogFragment(R.layout.dialog_recycler_view
         }
         }
     }
     }
 
 
-}
+}

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

@@ -43,6 +43,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.ActivityAudioPlayBinding
 import io.legado.app.databinding.ActivityAudioPlayBinding
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.removeType
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
@@ -396,10 +397,10 @@ class AudioPlayActivity :
                 AudioPlay.setTimer(0)
                 AudioPlay.setTimer(0)
             }
             }
             binding.settingSlider.apply {
             binding.settingSlider.apply {
-                valueFrom = 0f
-                valueTo = 180f
+                valueFrom = PlaybackTimer.MIN_MINUTES.toFloat()
+                valueTo = PlaybackTimer.MAX_MINUTES.toFloat()
                 stepSize = 1f
                 stepSize = 1f
-                value = AudioPlayService.timeMinute.toFloat()
+                value = PlaybackTimer.normalize(AudioPlayService.timeMinute).toFloat()
 
 
                 addOnChangeListener { _, newValue, fromUser ->
                 addOnChangeListener { _, newValue, fromUser ->
                     if (fromUser) AudioPlay.setTimer(newValue.toInt())
                     if (fromUser) AudioPlay.setTimer(newValue.toInt())
@@ -798,4 +799,3 @@ private fun Int.copy(alpha: Float): Int {
     val alpha = (alpha * 255).toInt()
     val alpha = (alpha * 255).toInt()
     return (this and 0x00FFFFFF) or (alpha shl 24)
     return (this and 0x00FFFFFF) or (alpha shl 24)
 }
 }
-

+ 101 - 22
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageScreen.kt

@@ -2,6 +2,7 @@ package io.legado.app.ui.book.cache.manage
 
 
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
@@ -42,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppFloatingActionButton
@@ -51,16 +53,27 @@ import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.card.TextCard
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.icon.AppIcon
+import io.legado.app.ui.widget.components.icon.AppIcons
+import io.legado.app.ui.widget.components.list.ListUiState
+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.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
 import org.koin.androidx.compose.koinViewModel
 
 
+private data class BookCacheManageListState(
+    override val items: List<BookCacheBookItem> = emptyList(),
+    override val selectedIds: Set<Any> = emptySet(),
+    override val searchKey: String = "",
+    override val isSearch: Boolean = false,
+    override val isLoading: Boolean = false,
+) : ListUiState<BookCacheBookItem>
+
 @Composable
 @Composable
 fun BookCacheManageRouteScreen(
 fun BookCacheManageRouteScreen(
     onBackClick: () -> Unit,
     onBackClick: () -> Unit,
@@ -95,24 +108,86 @@ private fun BookCacheManageScreen(
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) }
     var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(null) }
     var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
     var pendingDeleteChapter by remember { mutableStateOf<Pair<BookCacheBookItem, BookCacheChapterItem>?>(null) }
+    var isSearchMode by remember { mutableStateOf(false) }
+    var searchKey by remember { mutableStateOf("") }
+    var selectedGroupId by remember { mutableStateOf(BookGroup.IdAll) }
+    var showGroupMenu by remember { mutableStateOf(false) }
     val allBooks = state.shelfBooks + state.notShelfBooks
     val allBooks = state.shelfBooks + state.notShelfBooks
-    val hasRunningDownload = allBooks.any { it.hasActiveDownload }
-    val hasDownloadTarget = allBooks.any { it.cachedCount < it.totalCount }
+    val filteredBooks = remember(allBooks, searchKey, isSearchMode, selectedGroupId) {
+        allBooks.filter { book ->
+            val matchesGroup = when (selectedGroupId) {
+                BookGroup.IdAll -> true
+                BookGroup.IdNetNone -> !book.isNotShelf && book.group == 0L
+                else -> !book.isNotShelf && (book.group and selectedGroupId) > 0L
+            }
+            val matchesSearch = !isSearchMode || searchKey.isBlank() ||
+                    book.name.contains(searchKey.trim(), ignoreCase = true) ||
+                    book.author.contains(searchKey.trim(), ignoreCase = true)
+            matchesGroup && matchesSearch
+        }
+    }
+    val filteredShelfBooks = remember(filteredBooks) { filteredBooks.filterNot { it.isNotShelf } }
+    val filteredNotShelfBooks = remember(filteredBooks) { filteredBooks.filter { it.isNotShelf } }
+    val hasRunningDownload = filteredBooks.any { it.hasActiveDownload }
+    val hasDownloadTarget = filteredBooks.any { it.cachedCount < it.totalCount }
+    val listUiState = remember(filteredBooks, searchKey, isSearchMode) {
+        BookCacheManageListState(
+            items = filteredBooks,
+            searchKey = searchKey,
+            isSearch = isSearchMode,
+        )
+    }
     val bookshelfSectionTitle = stringResource(R.string.cache_section_bookshelf)
     val bookshelfSectionTitle = stringResource(R.string.cache_section_bookshelf)
     val bookshelfSectionEmptyText = stringResource(R.string.cache_empty_bookshelf)
     val bookshelfSectionEmptyText = stringResource(R.string.cache_empty_bookshelf)
     val notBookshelfSectionTitle = stringResource(R.string.cache_section_not_in_bookshelf)
     val notBookshelfSectionTitle = stringResource(R.string.cache_section_not_in_bookshelf)
     val notBookshelfSectionEmptyText = stringResource(R.string.cache_empty_not_in_bookshelf)
     val notBookshelfSectionEmptyText = stringResource(R.string.cache_empty_not_in_bookshelf)
+    val allGroupText = stringResource(R.string.all)
+    val noGroupText = stringResource(R.string.net_no_group)
+    val groupOptions = remember(state.groups, allGroupText, noGroupText) {
+        listOf(
+            BookGroup.IdAll to allGroupText,
+            BookGroup.IdNetNone to noGroupText,
+        ) + state.groups.map { it.groupId to it.groupName }
+    }
 
 
     AppScaffold(
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
         topBar = {
-            GlassMediumFlexibleTopAppBar(
+            DynamicTopAppBar(
                 title = stringResource(R.string.cache_management),
                 title = stringResource(R.string.cache_management),
                 subtitle = state.downloadSummary.takeIf { it.isNotBlank() },
                 subtitle = state.downloadSummary.takeIf { it.isNotBlank() },
-                navigationIcon = {
-                    TopBarNavigationButton(onClick = onBackClick)
+                state = listUiState,
+                onBackClick = onBackClick,
+                onSearchToggle = { active ->
+                    isSearchMode = active
+                    if (!active) searchKey = ""
                 },
                 },
-                actions = {
+                onSearchQueryChange = { searchKey = it },
+                searchPlaceholder = "筛选书名/作者",
+                onClearSelection = {},
+                topBarActions = {
+                    Box {
+                        TopBarActionButton(
+                            onClick = { showGroupMenu = true },
+                            imageVector = AppIcons.Filter,
+                            contentDescription = stringResource(R.string.a11y_group_filter)
+                        )
+                        RoundDropdownMenu(
+                            expanded = showGroupMenu,
+                            onDismissRequest = { showGroupMenu = false }
+                        ) { dismiss ->
+                            groupOptions.forEach { (groupId, groupName) ->
+                                RoundDropdownMenuItem(
+                                    text = groupName,
+                                    isSelected = groupId == selectedGroupId,
+                                    onClick = {
+                                        selectedGroupId = groupId
+                                        dismiss()
+                                    }
+                                )
+                            }
+                        }
+                    }
                     TopBarActionButton(
                     TopBarActionButton(
                         onClick = { onIntent(BookCacheManageIntent.Refresh) },
                         onClick = { onIntent(BookCacheManageIntent.Refresh) },
                         imageVector = Icons.Default.Refresh,
                         imageVector = Icons.Default.Refresh,
@@ -166,20 +241,7 @@ private fun BookCacheManageScreen(
                 cacheSection(
                 cacheSection(
                     title = bookshelfSectionTitle,
                     title = bookshelfSectionTitle,
                     emptyText = bookshelfSectionEmptyText,
                     emptyText = bookshelfSectionEmptyText,
-                    books = state.shelfBooks,
-                    expandedBookUrls = state.expandedBookUrls,
-                    chaptersByBookUrl = state.chaptersByBookUrl,
-                    onToggleExpanded = { bookUrl ->
-                        onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
-                    },
-                    onIntent = onIntent,
-                    onDeleteBook = { pendingDeleteBook = it },
-                    onDeleteChapter = { book, chapter -> pendingDeleteChapter = book to chapter }
-                )
-                cacheSection(
-                    title = notBookshelfSectionTitle,
-                    emptyText = notBookshelfSectionEmptyText,
-                    books = state.notShelfBooks,
+                    books = filteredShelfBooks,
                     expandedBookUrls = state.expandedBookUrls,
                     expandedBookUrls = state.expandedBookUrls,
                     chaptersByBookUrl = state.chaptersByBookUrl,
                     chaptersByBookUrl = state.chaptersByBookUrl,
                     onToggleExpanded = { bookUrl ->
                     onToggleExpanded = { bookUrl ->
@@ -189,6 +251,23 @@ private fun BookCacheManageScreen(
                     onDeleteBook = { pendingDeleteBook = it },
                     onDeleteBook = { pendingDeleteBook = it },
                     onDeleteChapter = { book, chapter -> pendingDeleteChapter = book to chapter }
                     onDeleteChapter = { book, chapter -> pendingDeleteChapter = book to chapter }
                 )
                 )
+                if (selectedGroupId == BookGroup.IdAll) {
+                    cacheSection(
+                        title = notBookshelfSectionTitle,
+                        emptyText = notBookshelfSectionEmptyText,
+                        books = filteredNotShelfBooks,
+                        expandedBookUrls = state.expandedBookUrls,
+                        chaptersByBookUrl = state.chaptersByBookUrl,
+                        onToggleExpanded = { bookUrl ->
+                            onIntent(BookCacheManageIntent.ToggleBookExpanded(bookUrl))
+                        },
+                        onIntent = onIntent,
+                        onDeleteBook = { pendingDeleteBook = it },
+                        onDeleteChapter = { book, chapter ->
+                            pendingDeleteChapter = book to chapter
+                        }
+                    )
+                }
             }
             }
         }
         }
     }
     }

+ 16 - 1
app/src/main/java/io/legado/app/ui/book/cache/manage/BookCacheManageViewModel.kt

@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookChapterDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.model.BookChapterCacheInfo
 import io.legado.app.data.model.BookChapterCacheInfo
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
@@ -25,12 +27,12 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.isActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 import java.util.concurrent.ConcurrentHashMap
 import java.util.concurrent.ConcurrentHashMap
@@ -40,6 +42,7 @@ data class BookCacheManageUiState(
     val isLoading: Boolean = true,
     val isLoading: Boolean = true,
     val shelfBooks: List<BookCacheBookItem> = emptyList(),
     val shelfBooks: List<BookCacheBookItem> = emptyList(),
     val notShelfBooks: List<BookCacheBookItem> = emptyList(),
     val notShelfBooks: List<BookCacheBookItem> = emptyList(),
+    val groups: List<BookGroup> = emptyList(),
     val expandedBookUrls: Set<String> = emptySet(),
     val expandedBookUrls: Set<String> = emptySet(),
     val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
     val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
     val downloadSummary: String = "",
     val downloadSummary: String = "",
@@ -59,6 +62,7 @@ data class BookCacheBookItem(
     val pausedCount: Int,
     val pausedCount: Int,
     val errorCount: Int,
     val errorCount: Int,
     val isNotShelf: Boolean,
     val isNotShelf: Boolean,
+    val group: Long,
 ) {
 ) {
     val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
     val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
     val hasActiveDownload: Boolean get() = waitingCount > 0 || downloadingCount > 0
     val hasActiveDownload: Boolean get() = waitingCount > 0 || downloadingCount > 0
@@ -107,6 +111,7 @@ class BookCacheManageViewModel(
     application: Application,
     application: Application,
     private val bookDao: BookDao,
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao,
     private val bookChapterDao: BookChapterDao,
+    private val bookGroupDao: BookGroupDao,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
 ) : BaseViewModel(application) {
 ) : BaseViewModel(application) {
@@ -163,6 +168,15 @@ class BookCacheManageViewModel(
                 reloadAll(books = books, forceDatabase = false)
                 reloadAll(books = books, forceDatabase = false)
             }
             }
         }
         }
+        viewModelScope.launch {
+            bookGroupDao.flowSelect().collect { groups ->
+                _uiState.update { state ->
+                    state.copy(
+                        groups = groups.filter { it.groupId > 0L }
+                    )
+                }
+            }
+        }
         viewModelScope.launch {
         viewModelScope.launch {
             CacheBook.cacheSuccessFlow.collect { chapter ->
             CacheBook.cacheSuccessFlow.collect { chapter ->
                 scheduleDownloadStatusRefresh(chapter.bookUrl)
                 scheduleDownloadStatusRefresh(chapter.bookUrl)
@@ -381,6 +395,7 @@ class BookCacheManageViewModel(
             pausedCount = pausedCount,
             pausedCount = pausedCount,
             errorCount = errorIndices.size,
             errorCount = errorIndices.size,
             isNotShelf = book.isNotShelf,
             isNotShelf = book.isNotShelf,
+            group = book.group,
         )
         )
     }
     }
 
 

+ 24 - 13
app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt

@@ -18,7 +18,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 
 class ChangeChapterSourceViewModel(
 class ChangeChapterSourceViewModel(
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
@@ -85,7 +87,9 @@ class ChangeChapterSourceViewModel(
         }
         }
         if (searchJob?.isActive != true) {
         if (searchJob?.isActive != true) {
             viewModelScope.launch {
             viewModelScope.launch {
-                val dbBooks = getDbSearchBooks()
+                val dbBooks = withContext(Dispatchers.IO) {
+                    getDbSearchBooks()
+                }
                 if (dbBooks.isNotEmpty()) {
                 if (dbBooks.isNotEmpty()) {
                     searchResults.clear()
                     searchResults.clear()
                     searchResults.addAll(dbBooks)
                     searchResults.addAll(dbBooks)
@@ -330,15 +334,19 @@ class ChangeChapterSourceViewModel(
     }
     }
 
 
     private fun refreshResults() {
     private fun refreshResults() {
-        searchResults.clear()
-        bookMap.clear()
-        val dbBooks = getDbSearchBooks()
-        if (dbBooks.isNotEmpty()) {
-            searchResults.addAll(dbBooks)
-            searchResults.forEach { bookMap[it.primaryStr()] = it }
-            filterResults()
-        } else {
-            startSearch()
+        viewModelScope.launch {
+            val dbBooks = withContext(Dispatchers.IO) {
+                getDbSearchBooks()
+            }
+            searchResults.clear()
+            bookMap.clear()
+            if (dbBooks.isNotEmpty()) {
+                searchResults.addAll(dbBooks)
+                searchResults.forEach { bookMap[it.primaryStr()] = it }
+                filterResults()
+            } else {
+                startSearch()
+            }
         }
         }
     }
     }
 
 
@@ -387,7 +395,9 @@ class ChangeChapterSourceViewModel(
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
             try {
             try {
-                val (toc, _) = getChapterContentUseCase.getToc(book)
+                val (toc, _) = withContext(Dispatchers.IO) {
+                    getChapterContentUseCase.getToc(book)
+                }
                 val currentTocIndex = getChapterContentUseCase.getDurChapterIndex(
                 val currentTocIndex = getChapterContentUseCase.getDurChapterIndex(
                     chapterIndex = chapterIndex,
                     chapterIndex = chapterIndex,
                     chapterTitle = chapterTitle,
                     chapterTitle = chapterTitle,
@@ -401,7 +411,7 @@ class ChangeChapterSourceViewModel(
                         currentTocIndex = currentTocIndex,
                         currentTocIndex = currentTocIndex,
                     )
                     )
                 }
                 }
-            } catch (e: Exception) {
+            } catch (_: Exception) {
                 _uiState.update {
                 _uiState.update {
                     it.copy(
                     it.copy(
                         showToc = false,
                         showToc = false,
@@ -426,8 +436,9 @@ class ChangeChapterSourceViewModel(
                 val searchBook = selectedSearchBook.toBook()
                 val searchBook = selectedSearchBook.toBook()
                 val toc = _uiState.value.tocItems
                 val toc = _uiState.value.tocItems
                 val nextChapterUrl = toc.getOrNull(chapter.index + 1)?.url
                 val nextChapterUrl = toc.getOrNull(chapter.index + 1)?.url
-                val content =
+                val content = withContext(Dispatchers.IO) {
                     getChapterContentUseCase.getContent(searchBook, chapter, nextChapterUrl)
                     getChapterContentUseCase.getContent(searchBook, chapter, nextChapterUrl)
+                }
                 _uiState.update { it.copy(isLoadingToc = false) }
                 _uiState.update { it.copy(isLoadingToc = false) }
                 _effects.tryEmit(ChangeChapterSourceEffect.ReplaceContent(content))
                 _effects.tryEmit(ChangeChapterSourceEffect.ReplaceContent(content))
                 _effects.tryEmit(ChangeChapterSourceEffect.Dismiss)
                 _effects.tryEmit(ChangeChapterSourceEffect.Dismiss)

+ 5 - 3
app/src/main/java/io/legado/app/ui/book/explore/ExploreShowScreen.kt

@@ -42,12 +42,14 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.R
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookCoverSharedElementKey
@@ -264,20 +266,20 @@ fun ExploreShowScreen(
                                 )
                                 )
                             },
                             },
                             imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
                             imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
-                            contentDescription = "列数设置"
+                            contentDescription = stringResource(R.string.a11y_grid_columns)
                         )
                         )
                     }
                     }
 
 
                     TopBarActionButton(
                     TopBarActionButton(
                         onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
                         onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
                         imageVector = Icons.Outlined.FilterAlt,
                         imageVector = Icons.Outlined.FilterAlt,
-                        contentDescription = "分类"
+                        contentDescription = stringResource(R.string.select_or_search_category)
                     )
                     )
 
 
                     TopBarActionButton(
                     TopBarActionButton(
                         onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
                         onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
-                        contentDescription = "切换布局"
+                        contentDescription = stringResource(R.string.a11y_switch_layout)
                     )
                     )
                 },
                 },
                 scrollBehavior = scrollBehavior
                 scrollBehavior = scrollBehavior

+ 25 - 3
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookScreen.kt

@@ -86,6 +86,7 @@ private fun ImportBookContent(
     onSearchToggle: (Boolean) -> Unit,
     onSearchToggle: (Boolean) -> Unit,
     onSearchQueryChange: (String) -> Unit,
     onSearchQueryChange: (String) -> Unit,
     onSelectFolder: () -> Unit,
     onSelectFolder: () -> Unit,
+    onSelectBookFiles: () -> Unit,
     onScanFolder: () -> Unit,
     onScanFolder: () -> Unit,
     onImportFileName: () -> Unit,
     onImportFileName: () -> Unit,
     onSortChange: (Int) -> Unit,
     onSortChange: (Int) -> Unit,
@@ -192,7 +193,7 @@ private fun ImportBookContent(
                 )
                 )
             )
             )
         ),
         ),
-        onAddClick = null
+        onAddClick = onSelectBookFiles
     ) { paddingValues ->
     ) { paddingValues ->
         AppPullToRefresh(
         AppPullToRefresh(
             modifier = Modifier
             modifier = Modifier
@@ -252,6 +253,11 @@ fun ImportBookScreen(
     ) { uri ->
     ) { uri ->
         viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget))
         viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget))
     }
     }
+    val selectBookFiles = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenMultipleDocuments()
+    ) { uris ->
+        viewModel.dispatch(ImportBookIntent.BookFilesPicked(uris))
+    }
     val handleBack = {
     val handleBack = {
         when {
         when {
             uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
             uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
@@ -359,6 +365,21 @@ fun ImportBookScreen(
         onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
         onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
         onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
         onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
         onSelectFolder = { showFolderPicker = true },
         onSelectFolder = { showFolderPicker = true },
+        onSelectBookFiles = {
+            selectBookFiles.launch(
+                arrayOf(
+                    "text/plain",
+                    "application/epub+zip",
+                    "application/pdf",
+                    "application/x-mobipocket-ebook",
+                    "application/vnd.amazon.ebook",
+                    "application/zip",
+                    "application/x-rar-compressed",
+                    "application/x-7z-compressed",
+                    "application/octet-stream"
+                )
+            )
+        },
         onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
         onScanFolder = { viewModel.dispatch(ImportBookIntent.ScanFolder) },
         onImportFileName = {
         onImportFileName = {
             fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
             fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
@@ -434,7 +455,7 @@ private fun ImportPathNavigationBar(
             SmallTonalButton(
             SmallTonalButton(
                 onClick = onNavigateBack,
                 onClick = onNavigateBack,
                 icon = Icons.AutoMirrored.Filled.ArrowBack,
                 icon = Icons.AutoMirrored.Filled.ArrowBack,
-                contentDescription = "back"
+                contentDescription = stringResource(R.string.back)
             )
             )
         }
         }
     }
     }
@@ -517,7 +538,8 @@ private fun ImportBookItem(
                 Spacer(modifier = Modifier.width(8.dp))
                 Spacer(modifier = Modifier.width(8.dp))
                 SmallPlainButton(
                 SmallPlainButton(
                     onClick = onAddToBookshelf,
                     onClick = onAddToBookshelf,
-                    icon = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline
+                    icon = if (isSelected) Icons.Default.Check else Icons.Default.AddCircleOutline,
+                    contentDescription = stringResource(R.string.add_to_bookshelf)
                 )
                 )
             }
             }
         }
         }

+ 19 - 6
app/src/main/java/io/legado/app/ui/book/import/local/ImportBookViewModel.kt

@@ -1,13 +1,12 @@
 package io.legado.app.ui.book.import.local
 package io.legado.app.ui.book.import.local
 
 
-import io.legado.app.ui.config.otherConfig.OtherConfig
 import android.app.Application
 import android.app.Application
 import android.net.Uri
 import android.net.Uri
 import android.provider.DocumentsContract
 import android.provider.DocumentsContract
 import android.provider.DocumentsContract.getTreeDocumentId
 import android.provider.DocumentsContract.getTreeDocumentId
+import androidx.compose.runtime.Immutable
 import androidx.core.net.toUri
 import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
 import androidx.documentfile.provider.DocumentFile
-import androidx.compose.runtime.Immutable
 import androidx.lifecycle.viewModelScope
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
@@ -18,6 +17,7 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.ui.config.importBookConfig.ImportBookConfig
 import io.legado.app.ui.config.importBookConfig.ImportBookConfig
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.InteractionState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.ui.widget.components.list.ListUiState
 import io.legado.app.utils.AlphanumComparator
 import io.legado.app.utils.AlphanumComparator
@@ -35,19 +35,17 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.flowOn
 import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
@@ -79,6 +77,7 @@ sealed interface ImportBookIntent {
     data object Initialize : ImportBookIntent
     data object Initialize : ImportBookIntent
     data object SelectFolderClick : ImportBookIntent
     data object SelectFolderClick : ImportBookIntent
     data class FolderPicked(val uri: Uri?, val target: ImportFolderPickTarget) : ImportBookIntent
     data class FolderPicked(val uri: Uri?, val target: ImportFolderPickTarget) : ImportBookIntent
+    data class BookFilesPicked(val uris: List<Uri>) : ImportBookIntent
     data class SearchToggle(val enabled: Boolean) : ImportBookIntent
     data class SearchToggle(val enabled: Boolean) : ImportBookIntent
     data class SearchQueryChange(val query: String) : ImportBookIntent
     data class SearchQueryChange(val query: String) : ImportBookIntent
     data class SortChange(val sort: Int) : ImportBookIntent
     data class SortChange(val sort: Int) : ImportBookIntent
@@ -149,6 +148,7 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
             }
             }
 
 
             is ImportBookIntent.FolderPicked -> onFolderPicked(intent.uri, intent.target)
             is ImportBookIntent.FolderPicked -> onFolderPicked(intent.uri, intent.target)
+            is ImportBookIntent.BookFilesPicked -> importPickedBookFiles(intent.uris)
             is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled)
             is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled)
             is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
             is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
             is ImportBookIntent.SortChange -> setSort(intent.sort)
             is ImportBookIntent.SortChange -> setSort(intent.sort)
@@ -548,6 +548,19 @@ class ImportBookViewModel(application: Application) : BaseViewModel(application)
         }
         }
     }
     }
 
 
+    private fun importPickedBookFiles(uris: List<Uri>) {
+        if (uris.isEmpty()) return
+        uris.forEach { it.takePersistablePermissionSafely(context) }
+        execute {
+            LocalBook.importFiles(uris)
+        }.onError {
+            context.toastOnUi("添加书架失败,请重新选择书籍文件")
+            AppLog.put("添加书架失败\n${it.localizedMessage}", it)
+        }.onSuccess {
+            context.toastOnUi("添加书架成功")
+        }
+    }
+
     private fun addSingleToBookshelf(item: ImportBook) {
     private fun addSingleToBookshelf(item: ImportBook) {
         if (item.isDir || item.isOnBookShelf) return
         if (item.isDir || item.isOnBookShelf) return
         execute {
         execute {

+ 9 - 6
app/src/main/java/io/legado/app/ui/book/import/remote/RemoteBookScreen.kt

@@ -170,7 +170,8 @@ fun RemoteBookScreen(
             {
             {
                 MediumPlainButton(
                 MediumPlainButton(
                     onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
                     onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
-                    icon = Icons.Default.Add
+                    icon = Icons.Default.Add,
+                    contentDescription = stringResource(R.string.add)
                 )
                 )
             }
             }
         } else {
         } else {
@@ -264,7 +265,7 @@ fun RemoteBookScreen(
             TopBarActionButton(
             TopBarActionButton(
                 onClick = { showSheet = RemoteBookSheet.Servers },
                 onClick = { showSheet = RemoteBookSheet.Servers },
                 imageVector = Icons.Default.Storage,
                 imageVector = Icons.Default.Storage,
-                contentDescription = "服务器"
+                contentDescription = stringResource(R.string.a11y_server_list)
             )
             )
         },
         },
         dropDownMenuContent = { dismiss ->
         dropDownMenuContent = { dismiss ->
@@ -438,14 +439,14 @@ private fun ServerItem(
                         SmallPlainButton(
                         SmallPlainButton(
                             onClick = it,
                             onClick = it,
                             icon = Icons.Default.Edit,
                             icon = Icons.Default.Edit,
-                            contentDescription = "Edit"
+                            contentDescription = stringResource(R.string.edit)
                         )
                         )
                     }
                     }
                     onDelete?.let {
                     onDelete?.let {
                         SmallPlainButton(
                         SmallPlainButton(
                             onClick = it,
                             onClick = it,
                             icon = Icons.Default.Delete,
                             icon = Icons.Default.Delete,
-                            contentDescription = "Delete"
+                            contentDescription = stringResource(R.string.delete)
                         )
                         )
                     }
                     }
                 }
                 }
@@ -519,7 +520,9 @@ private fun ServerConfigSheetContent(
                 IconButton(onClick = { passwordVisible = !passwordVisible }) {
                 IconButton(onClick = { passwordVisible = !passwordVisible }) {
                     Icon(
                     Icon(
                         if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
                         if (passwordVisible) Icons.Default.Visibility else Icons.Default.VisibilityOff,
-                        contentDescription = null
+                        contentDescription = stringResource(
+                            if (passwordVisible) R.string.hide_password else R.string.show_password
+                        )
                     )
                     )
                 }
                 }
             }
             }
@@ -610,7 +613,7 @@ private fun PathNavigationBar(
             SmallTonalButton(
             SmallTonalButton(
                 onClick = onNavigateBack,
                 onClick = onNavigateBack,
                 icon = Icons.AutoMirrored.Filled.ArrowBack,
                 icon = Icons.AutoMirrored.Filled.ArrowBack,
-                contentDescription = "返回上级"
+                contentDescription = stringResource(R.string.a11y_parent_folder)
             )
             )
         }
         }
     }
     }

+ 31 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoBackdropStyle.kt

@@ -0,0 +1,31 @@
+package io.legado.app.ui.book.info
+
+import io.legado.app.ui.config.themeConfig.ThemeConfig
+
+internal data class BookInfoBackdropStyle(
+    val showCover: Boolean,
+    val blurCover: Boolean,
+    val applySeedOverlay: Boolean,
+)
+
+internal fun resolveBookInfoBackdropStyle(backgroundMode: String): BookInfoBackdropStyle {
+    return when (backgroundMode) {
+        ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_OFF -> BookInfoBackdropStyle(
+            showCover = true,
+            blurCover = false,
+            applySeedOverlay = true,
+        )
+
+        ThemeConfig.BOOK_INFO_BACKGROUND_COVER_HIDDEN -> BookInfoBackdropStyle(
+            showCover = false,
+            blurCover = false,
+            applySeedOverlay = false,
+        )
+
+        else -> BookInfoBackdropStyle(
+            showCover = true,
+            blurCover = true,
+            applySeedOverlay = true,
+        )
+    }
+}

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

@@ -22,6 +22,7 @@ data class HighlightedTag(
 data class BookInfoUiState(
 data class BookInfoUiState(
     val book: BookInfoBookUi? = null,
     val book: BookInfoBookUi? = null,
     val hasChapters: Boolean = false,
     val hasChapters: Boolean = false,
+    val tocLoadFailed: Boolean = false,
     val webFiles: List<BookInfoWebFile> = emptyList(),
     val webFiles: List<BookInfoWebFile> = emptyList(),
     val highlightedTags: List<HighlightedTag> = emptyList(),
     val highlightedTags: List<HighlightedTag> = emptyList(),
     val kindLabels: List<String> = emptyList(),
     val kindLabels: List<String> = emptyList(),
@@ -62,6 +63,7 @@ data class BookInfoBookUi(
     val latestChapterTitle: String?,
     val latestChapterTitle: String?,
     val totalChapterNum: Int,
     val totalChapterNum: Int,
     val durChapterIndex: Int,
     val durChapterIndex: Int,
+    val durChapterPos: Int,
     val remark: String?,
     val remark: String?,
     val displayIntro: String?,
     val displayIntro: String?,
 )
 )
@@ -70,6 +72,7 @@ data class BookInfoBookUi(
 data class BookInfoSourceUi(
 data class BookInfoSourceUi(
     val sourceUrl: String,
     val sourceUrl: String,
     val hasLogin: Boolean,
     val hasLogin: Boolean,
+    val hasCustomButton: Boolean,
 )
 )
 
 
 @Stable
 @Stable
@@ -272,9 +275,11 @@ sealed interface BookInfoCallbackAction {
     data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
     data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
     data class CopyText(val text: String) : BookInfoCallbackAction
     data class CopyText(val text: String) : BookInfoCallbackAction
     data object ClearCache : BookInfoCallbackAction
     data object ClearCache : BookInfoCallbackAction
+    data object None : BookInfoCallbackAction
 }
 }
 
 
 enum class BookInfoMenuAction {
 enum class BookInfoMenuAction {
+    CustomButton,
     Edit,
     Edit,
     Share,
     Share,
     Upload,
     Upload,

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

@@ -14,14 +14,12 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
 import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isImage
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocal
-import io.legado.app.help.config.AppConfig
-import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.audio.AudioPlayActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
 import io.legado.app.ui.book.info.edit.BookInfoEditActivity
@@ -29,6 +27,7 @@ import io.legado.app.ui.book.manga.ReadMangaActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.otherConfig.OtherConfig
+import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.file.HandleFileContract
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
 import io.legado.app.ui.widget.dialog.VariableDialog
@@ -272,6 +271,8 @@ private fun runSourceCallback(
             BookInfoCallbackAction.ClearCache -> {
             BookInfoCallbackAction.ClearCache -> {
                 viewModel.clearCache()
                 viewModel.clearCache()
             }
             }
+
+            BookInfoCallbackAction.None -> Unit
         }
         }
     }
     }
 }
 }

+ 117 - 37
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -160,9 +160,24 @@ fun BookInfoScreen(
             canApplyCoverTheme = true
             canApplyCoverTheme = true
         }
         }
     }
     }
-    val bookColorTheme = rememberBookInfoColorTheme(state.book)
+    val initiallyUsesDefaultCover = state.book?.let { usesDefaultBookCover(it.coverPath) } ?: true
+    var usesDefaultCover by remember(
+        state.book?.bookUrl,
+        state.book?.coverPath,
+        initiallyUsesDefaultCover,
+    ) {
+        mutableStateOf(initiallyUsesDefaultCover)
+    }
+    val backdropStyle = state.book?.let { resolveBookInfoBackdropStyle(it, usesDefaultCover) }
+    val bookColorTheme = rememberBookInfoColorTheme(
+        book = state.book,
+        enabled = backdropStyle?.showCover == true,
+        usesDefaultCover = usesDefaultCover,
+    )
 
 
-    BookInfoColorTheme(theme = bookColorTheme.takeIf { canApplyCoverTheme }) {
+    BookInfoColorTheme(theme = bookColorTheme.takeIf {
+        canApplyCoverTheme && backdropStyle?.showCover == true
+    }) {
         BookInfoScreenContent(
         BookInfoScreenContent(
             state = state,
             state = state,
             onIntent = onIntent,
             onIntent = onIntent,
@@ -170,6 +185,13 @@ fun BookInfoScreen(
             sharedTransitionScope = sharedTransitionScope,
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = animatedVisibilityScope,
             animatedVisibilityScope = animatedVisibilityScope,
             sharedCoverKey = sharedCoverKey,
             sharedCoverKey = sharedCoverKey,
+            backdropStyle = backdropStyle,
+            usesDefaultCover = usesDefaultCover,
+            onNetworkCoverLoadError = { failedCoverPath ->
+                if (failedCoverPath == state.book?.coverPath) {
+                    usesDefaultCover = true
+                }
+            },
         )
         )
     }
     }
 }
 }
@@ -185,6 +207,9 @@ private fun BookInfoScreenContent(
     sharedTransitionScope: SharedTransitionScope?,
     sharedTransitionScope: SharedTransitionScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     sharedCoverKey: String?,
     sharedCoverKey: String?,
+    backdropStyle: BookInfoBackdropStyle?,
+    usesDefaultCover: Boolean,
+    onNetworkCoverLoadError: (String?) -> Unit,
 ) {
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val scrollBehavior = if (isMiuix) {
     val scrollBehavior = if (isMiuix) {
@@ -224,9 +249,13 @@ private fun BookInfoScreenContent(
         if (book == null) {
         if (book == null) {
             Box(modifier = Modifier.fillMaxSize())
             Box(modifier = Modifier.fillMaxSize())
         } else {
         } else {
+            val resolvedBackdropStyle = requireNotNull(backdropStyle)
             Box(modifier = Modifier.fillMaxSize()) {
             Box(modifier = Modifier.fillMaxSize()) {
                 BookInfoBackdrop(
                 BookInfoBackdrop(
                     book = book,
                     book = book,
+                    style = resolvedBackdropStyle,
+                    usesDefaultCover = usesDefaultCover,
+                    onNetworkCoverLoadError = onNetworkCoverLoadError,
                 )
                 )
                 AppPullToRefresh(
                 AppPullToRefresh(
                     modifier = Modifier.fillMaxSize(),
                     modifier = Modifier.fillMaxSize(),
@@ -254,6 +283,11 @@ private fun BookInfoScreenContent(
                                 onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
                                 onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
                                 onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
                                 onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
                                 onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
                                 onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
+                                onNetworkCoverLoadError = {
+                                    onNetworkCoverLoadError(book.coverPath)
+                                },
+                                usesDefaultCover = usesDefaultCover,
+                                applySeedOverlay = resolvedBackdropStyle.applySeedOverlay,
                                 sharedTransitionScope = sharedTransitionScope,
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
                                 sharedCoverKey = sharedCoverKey,
                                 sharedCoverKey = sharedCoverKey,
@@ -308,7 +342,7 @@ private fun BookInfoScreenContent(
                                 }
                                 }
                                 BookInfoSummary(
                                 BookInfoSummary(
                                     book = book,
                                     book = book,
-                                    hasChapters = state.hasChapters,
+                                    tocLoadFailed = state.tocLoadFailed,
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
                                 )
                                 )
                             }
                             }
@@ -507,9 +541,12 @@ private fun BookInfoTransparentTopAppBar(
 @Composable
 @Composable
 private fun rememberBookInfoColorTheme(
 private fun rememberBookInfoColorTheme(
     book: BookInfoBookUi?,
     book: BookInfoBookUi?,
+    enabled: Boolean,
+    usesDefaultCover: Boolean,
 ): ThemeOverrideState? {
 ): ThemeOverrideState? {
     if (
     if (
         book == null ||
         book == null ||
+        !enabled ||
         !ThemeConfig.bookInfoFollowCoverColor
         !ThemeConfig.bookInfoFollowCoverColor
     ) {
     ) {
         return null
         return null
@@ -517,18 +554,17 @@ private fun rememberBookInfoColorTheme(
 
 
     val imageLoader = koinInject<ImageLoader>()
     val imageLoader = koinInject<ImageLoader>()
     val isNight = LegadoTheme.isDark
     val isNight = LegadoTheme.isDark
-    val useDefaultCover = usesDefaultBookCover(book.coverPath)
     val defaultCoverPaths =
     val defaultCoverPaths =
         if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
         if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
     val coverPath = remember(
     val coverPath = remember(
         book.name,
         book.name,
         book.author,
         book.author,
         book.coverPath,
         book.coverPath,
-        useDefaultCover,
+        usesDefaultCover,
         isNight,
         isNight,
         defaultCoverPaths,
         defaultCoverPaths,
     ) {
     ) {
-        if (useDefaultCover) {
+        if (usesDefaultCover) {
             BookCoverModel.getRandomDefaultPath(
             BookCoverModel.getRandomDefaultPath(
                 seed = book.name,
                 seed = book.name,
                 isNight = isNight,
                 isNight = isNight,
@@ -537,8 +573,8 @@ private fun rememberBookInfoColorTheme(
             book.coverPath
             book.coverPath
         }
         }
     } ?: return null
     } ?: return null
-    val sourceOrigin = if (useDefaultCover) null else book.origin
-    val loadOnlyWifi = !useDefaultCover && CoverConfig.loadCoverOnlyWifi
+    val sourceOrigin = if (usesDefaultCover) null else book.origin
+    val loadOnlyWifi = !usesDefaultCover && CoverConfig.loadCoverOnlyWifi
     val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
     val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
         listOf(coverPath, sourceOrigin, loadOnlyWifi)
         listOf(coverPath, sourceOrigin, loadOnlyWifi)
     }
     }
@@ -555,6 +591,18 @@ private fun rememberBookInfoColorTheme(
     return rememberThemeOverride(seedColor)
     return rememberThemeOverride(seedColor)
 }
 }
 
 
+private fun resolveBookInfoBackdropStyle(
+    book: BookInfoBookUi,
+    usesDefaultCover: Boolean,
+): BookInfoBackdropStyle {
+    val backgroundMode = if (usesDefaultCover) {
+        ThemeConfig.bookInfoDefaultCoverBackground
+    } else {
+        ThemeConfig.bookInfoNetworkCoverBackground
+    }
+    return resolveBookInfoBackdropStyle(backgroundMode)
+}
+
 @Composable
 @Composable
 private fun BookInfoTopBarActions(
 private fun BookInfoTopBarActions(
     state: BookInfoUiState,
     state: BookInfoUiState,
@@ -593,23 +641,22 @@ private fun BookInfoTopBarActions(
 @Composable
 @Composable
 private fun BookInfoBackdrop(
 private fun BookInfoBackdrop(
     book: BookInfoBookUi,
     book: BookInfoBookUi,
+    style: BookInfoBackdropStyle,
+    usesDefaultCover: Boolean,
+    onNetworkCoverLoadError: (String?) -> Unit,
 ) {
 ) {
-    val backgroundMode = ThemeConfig.bookInfoBackgroundBlur
-    val showBackgroundCover =
-        backgroundMode != ThemeConfig.BOOK_INFO_BACKGROUND_COVER_HIDDEN
-    val blurBackground = backgroundMode != ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_OFF
-
     val backdropState = remember(
     val backdropState = remember(
         book.name,
         book.name,
         book.author,
         book.author,
         book.coverPath,
         book.coverPath,
         book.origin,
         book.origin,
+        usesDefaultCover,
     ) {
     ) {
         BookInfoBackdropState(
         BookInfoBackdropState(
             name = book.name,
             name = book.name,
             author = book.author,
             author = book.author,
-            coverPath = book.coverPath,
-            sourceOrigin = book.origin,
+            coverPath = if (usesDefaultCover) null else book.coverPath,
+            sourceOrigin = if (usesDefaultCover) null else book.origin,
         )
         )
     }
     }
     val seedOverlay = lerp(
     val seedOverlay = lerp(
@@ -622,7 +669,7 @@ private fun BookInfoBackdrop(
             .fillMaxSize()
             .fillMaxSize()
             .clearAndSetSemantics { }
             .clearAndSetSemantics { }
     ) {
     ) {
-        if (showBackgroundCover) {
+        if (style.showCover) {
             Crossfade(
             Crossfade(
                 targetState = backdropState,
                 targetState = backdropState,
                 animationSpec = tween(800),
                 animationSpec = tween(800),
@@ -638,7 +685,7 @@ private fun BookInfoBackdrop(
                         .fillMaxWidth()
                         .fillMaxWidth()
                         .height(480.dp)
                         .height(480.dp)
                         .then(
                         .then(
-                            if (blurBackground) {
+                            if (style.blurCover) {
                                 Modifier.blur(24.dp)
                                 Modifier.blur(24.dp)
                             } else {
                             } else {
                                 Modifier
                                 Modifier
@@ -646,17 +693,20 @@ private fun BookInfoBackdrop(
                         ),
                         ),
                     contentScale = ContentScale.Crop,
                     contentScale = ContentScale.Crop,
                     showLoadingPlaceholder = false,
                     showLoadingPlaceholder = false,
+                    onError = { onNetworkCoverLoadError(currentBook.coverPath) },
                     requestBuilder = {
                     requestBuilder = {
                         size(Size(384, 384))
                         size(Size(384, 384))
                     }
                     }
                 )
                 )
             }
             }
         }
         }
-        Box(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(seedOverlay.copy(alpha = 0.34f))
-        )
+        if (style.applySeedOverlay) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .background(seedOverlay.copy(alpha = 0.34f))
+            )
+        }
         Box(
         Box(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxSize()
                 .fillMaxSize()
@@ -664,8 +714,16 @@ private fun BookInfoBackdrop(
                     Brush.verticalGradient(
                     Brush.verticalGradient(
                         colorStops = arrayOf(
                         colorStops = arrayOf(
                             0f to Color.Transparent,
                             0f to Color.Transparent,
-                            0.20f to seedOverlay.copy(alpha = 0.10f),
-                            0.40f to seedOverlay.copy(alpha = 0.18f),
+                            0.20f to if (style.applySeedOverlay) {
+                                seedOverlay.copy(alpha = 0.10f)
+                            } else {
+                                Color.Transparent
+                            },
+                            0.40f to if (style.applySeedOverlay) {
+                                seedOverlay.copy(alpha = 0.18f)
+                            } else {
+                                LegadoTheme.colorScheme.surface.copy(alpha = 0.35f)
+                            },
                             0.60f to LegadoTheme.colorScheme.surface.copy(alpha = 0.85f),
                             0.60f to LegadoTheme.colorScheme.surface.copy(alpha = 0.85f),
                             0.80f to LegadoTheme.colorScheme.surface,
                             0.80f to LegadoTheme.colorScheme.surface,
                             1f to LegadoTheme.colorScheme.surface,
                             1f to LegadoTheme.colorScheme.surface,
@@ -692,6 +750,12 @@ private fun BookInfoOverflowMenu(
 ) {
 ) {
     val book = state.book
     val book = state.book
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+        if (state.bookSource?.hasCustomButton == true) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.custom_button),
+                onClick = { onMenuAction(BookInfoMenuAction.CustomButton) }
+            )
+        }
         if (state.inBookshelf) {
         if (state.inBookshelf) {
             RoundDropdownMenuItem(
             RoundDropdownMenuItem(
                 text = stringResource(R.string.edit),
                 text = stringResource(R.string.edit),
@@ -785,6 +849,9 @@ private fun BookInfoHeader(
     onAuthorClick: (Boolean) -> Unit,
     onAuthorClick: (Boolean) -> Unit,
     onBookNameClick: (Boolean) -> Unit,
     onBookNameClick: (Boolean) -> Unit,
     onOriginClick: () -> Unit,
     onOriginClick: () -> Unit,
+    onNetworkCoverLoadError: () -> Unit,
+    usesDefaultCover: Boolean,
+    applySeedOverlay: Boolean,
     sharedTransitionScope: SharedTransitionScope?,
     sharedTransitionScope: SharedTransitionScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     sharedCoverKey: String?,
     sharedCoverKey: String?,
@@ -797,8 +864,12 @@ private fun BookInfoHeader(
                 Brush.verticalGradient(
                 Brush.verticalGradient(
                     colors = listOf(
                     colors = listOf(
                         Color.Transparent,
                         Color.Transparent,
-                        lerp(LegadoTheme.colorScheme.surface, LegadoTheme.seedColor, 0.08f)
-                            .copy(alpha = 0.5f),
+                        if (applySeedOverlay) {
+                            lerp(LegadoTheme.colorScheme.surface, LegadoTheme.seedColor, 0.08f)
+                                .copy(alpha = 0.5f)
+                        } else {
+                            LegadoTheme.colorScheme.surface.copy(alpha = 0.5f)
+                        },
                         LegadoTheme.colorScheme.surface,
                         LegadoTheme.colorScheme.surface,
                     )
                     )
                 )
                 )
@@ -827,8 +898,9 @@ private fun BookInfoHeader(
                     CoilBookCover(
                     CoilBookCover(
                         name = book.name,
                         name = book.name,
                         author = book.author,
                         author = book.author,
-                        path = book.coverPath,
-                        sourceOrigin = book.origin,
+                        path = if (usesDefaultCover) null else book.coverPath,
+                        sourceOrigin = if (usesDefaultCover) null else book.origin,
+                        onError = onNetworkCoverLoadError,
                         modifier = Modifier
                         modifier = Modifier
                             .width(112.dp)
                             .width(112.dp)
                             .aspectRatio(5f / 7f),
                             .aspectRatio(5f / 7f),
@@ -1052,7 +1124,7 @@ private fun BookInfoActionCard(
 @Composable
 @Composable
 private fun BookInfoSummary(
 private fun BookInfoSummary(
     book: BookInfoBookUi,
     book: BookInfoBookUi,
-    hasChapters: Boolean,
+    tocLoadFailed: Boolean,
     onRemarkClick: () -> Unit,
     onRemarkClick: () -> Unit,
 ) {
 ) {
     Column(
     Column(
@@ -1088,17 +1160,25 @@ private fun BookInfoSummary(
                 color = LegadoTheme.colorScheme.secondary
                 color = LegadoTheme.colorScheme.secondary
             )
             )
             AnimatedTextLine(
             AnimatedTextLine(
-                text = if (book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0) "已读完" else stringResource(R.string.read_chapter_index, book.durChapterIndex + 1),
+                text = when {
+                    book.durChapterIndex == 0 && book.durChapterPos == 0 -> stringResource(R.string.is_unread)
+                    book.durChapterIndex + 1 == book.totalChapterNum && book.totalChapterNum > 0 -> "已读完"
+                    else -> stringResource(R.string.read_chapter_index, book.durChapterIndex + 1)
+                },
                 style = LegadoTheme.typography.labelMedium,
                 style = LegadoTheme.typography.labelMedium,
                 color = LegadoTheme.colorScheme.secondary,
                 color = LegadoTheme.colorScheme.secondary,
             )
             )
-        }
-        if (!hasChapters) {
-            AnimatedTextLine(
-                text = stringResource(R.string.error_load_toc),
-                style = LegadoTheme.typography.bodySmall,
-                color = LegadoTheme.colorScheme.error
-            )
+            if (tocLoadFailed) {
+                AppText(
+                    text = " · ",
+                    color = LegadoTheme.colorScheme.secondary
+                )
+                AnimatedTextLine(
+                    text = stringResource(R.string.error_load_toc),
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.error
+                )
+            }
         }
         }
         Spacer(modifier = Modifier.height(4.dp))
         Spacer(modifier = Modifier.height(4.dp))
         book.remark?.takeIf { it.isNotBlank() }?.let { remark ->
         book.remark?.takeIf { it.isNotBlank() }?.let { remark ->

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

@@ -148,13 +148,15 @@ fun GroupSelectSheet(
         startAction = {
         startAction = {
             MediumPlainButton(
             MediumPlainButton(
                 onClick = { showAddGroup = true },
                 onClick = { showAddGroup = true },
-                icon = Icons.Default.Add
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.group_add)
             )
             )
         },
         },
         endAction = {
         endAction = {
             MediumPlainButton(
             MediumPlainButton(
                 onClick = { onConfirm(selectedGroupId) },
                 onClick = { onConfirm(selectedGroupId) },
-                icon = Icons.Default.Check
+                icon = Icons.Default.Check,
+                contentDescription = stringResource(R.string.confirm)
             )
             )
         }
         }
     ) {
     ) {
@@ -190,7 +192,8 @@ fun GroupSelectSheet(
                         trailingAction = {
                         trailingAction = {
                             SmallPlainButton(
                             SmallPlainButton(
                                 onClick = { editingGroup = group },
                                 onClick = { editingGroup = group },
-                                icon = Icons.Default.Edit
+                                icon = Icons.Default.Edit,
+                                contentDescription = stringResource(R.string.edit)
                             )
                             )
                         },
                         },
                         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
                         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -239,7 +242,10 @@ fun ChangeCoverSheet(
         endAction = {
         endAction = {
             MediumPlainButton(
             MediumPlainButton(
                 onClick = { viewModel.startOrStopSearch() },
                 onClick = { viewModel.startOrStopSearch() },
-                icon = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh
+                icon = if (isSearching) Icons.Default.MoreVert else Icons.Default.Refresh,
+                contentDescription = stringResource(
+                    if (isSearching) R.string.more_menu else R.string.refresh
+                )
             )
             )
         }
         }
     ) {
     ) {

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

@@ -32,14 +32,15 @@ import io.legado.app.exception.NoBooksDirException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.addType
 import io.legado.app.help.book.addType
+import io.legado.app.help.book.getDisplayTagList
 import io.legado.app.help.book.getExportFileName
 import io.legado.app.help.book.getExportFileName
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.isSameNameAuthor
 import io.legado.app.help.book.isWebFile
 import io.legado.app.help.book.isWebFile
+import io.legado.app.help.book.parseHighlightedTags
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.removeType
 import io.legado.app.help.book.upKind
 import io.legado.app.help.book.upKind
-import io.legado.app.help.book.parseHighlightedTags
 import io.legado.app.help.book.updateTo
 import io.legado.app.help.book.updateTo
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.config.LocalConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
@@ -61,7 +62,6 @@ import io.legado.app.utils.ImageSaveUtils
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.UrlUtil
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CancellationException
@@ -70,14 +70,14 @@ import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.async
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.combine
@@ -140,6 +140,7 @@ class BookInfoViewModel(
             observeReadRecordIfNeeded(value)
             observeReadRecordIfNeeded(value)
         }
         }
     private var currentChapterList: List<BookChapter> = emptyList()
     private var currentChapterList: List<BookChapter> = emptyList()
+    private var tocLoadFailed = false
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
     private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
     private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
     private var currentCharacters: List<BookInfoCharacterUi> = emptyList()
     private var currentCharacters: List<BookInfoCharacterUi> = emptyList()
@@ -179,7 +180,8 @@ class BookInfoViewModel(
         origin: String? = null,
         origin: String? = null,
         coverPath: String? = null
         coverPath: String? = null
     ) {
     ) {
-        if (currentBook?.bookUrl == bookUrl) return
+        val current = currentBook
+        if (current != null) return
         _uiState.value = BookInfoUiState() // 立即重置 UI 状态
         _uiState.value = BookInfoUiState() // 立即重置 UI 状态
         currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
         currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
             Book(
             Book(
@@ -846,6 +848,7 @@ class BookInfoViewModel(
     private fun upBook(book: Book, source: BookSource?) {
     private fun upBook(book: Book, source: BookSource?) {
         currentBook = book
         currentBook = book
         currentChapterList = emptyList()
         currentChapterList = emptyList()
+        tocLoadFailed = false
         currentWebFiles = emptyList()
         currentWebFiles = emptyList()
         currentRelatedBooks = emptyList()
         currentRelatedBooks = emptyList()
         currentCharacters = emptyList()
         currentCharacters = emptyList()
@@ -907,7 +910,7 @@ class BookInfoViewModel(
             val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
             val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
             val normalizedGroupNames = groupNames.ifBlank { null }
             val normalizedGroupNames = groupNames.ifBlank { null }
             appDb.bookDao.update(book)
             appDb.bookDao.update(book)
-            val finalKinds = book.kind?.splitNotBlank(",", "\n").orEmpty().toList()
+            val finalKinds = book.getDisplayTagList()
             val enabledRules = appDb.highlightTagRuleDao.getEnabled()
             val enabledRules = appDb.highlightTagRuleDao.getEnabled()
             val (highlighted, regular) = parseHighlightedTags(finalKinds, enabledRules)
             val (highlighted, regular) = parseHighlightedTags(finalKinds, enabledRules)
             HighlightMeta(highlighted, regular, normalizedGroupNames, hasCustomGroup)
             HighlightMeta(highlighted, regular, normalizedGroupNames, hasCustomGroup)
@@ -933,6 +936,7 @@ class BookInfoViewModel(
         scope: CoroutineScope = viewModelScope,
         scope: CoroutineScope = viewModelScope,
         showLoading: Boolean = true,
         showLoading: Boolean = true,
     ) {
     ) {
+        tocLoadFailed = false
         syncUiState(isTocLoading = showLoading)
         syncUiState(isTocLoading = showLoading)
         if (book.isLocal) {
         if (book.isLocal) {
             execute(scope) {
             execute(scope) {
@@ -948,6 +952,7 @@ class BookInfoViewModel(
                 syncUiState(isTocLoading = false)
                 syncUiState(isTocLoading = false)
             }.onError {
             }.onError {
                 currentChapterList = emptyList()
                 currentChapterList = emptyList()
+                tocLoadFailed = true
                 syncUiState(isTocLoading = false)
                 syncUiState(isTocLoading = false)
             }
             }
         } else {
         } else {
@@ -974,6 +979,7 @@ class BookInfoViewModel(
                     syncUiState(isTocLoading = false)
                     syncUiState(isTocLoading = false)
                 }.onError {
                 }.onError {
                     currentChapterList = emptyList()
                     currentChapterList = emptyList()
+                    tocLoadFailed = true
                     syncUiState(isTocLoading = false)
                     syncUiState(isTocLoading = false)
                     AppLog.put("获取目录失败\n${it.localizedMessage}", it)
                     AppLog.put("获取目录失败\n${it.localizedMessage}", it)
                 }
                 }
@@ -1139,6 +1145,14 @@ class BookInfoViewModel(
     private fun handleMenuAction(action: BookInfoMenuAction) {
     private fun handleMenuAction(action: BookInfoMenuAction) {
         val book = currentBook ?: return
         val book = currentBook ?: return
         when (action) {
         when (action) {
+            BookInfoMenuAction.CustomButton -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_CUSTOM_BUTTON,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.None,
+                )
+            )
             BookInfoMenuAction.Edit -> openEdit()
             BookInfoMenuAction.Edit -> openEdit()
             BookInfoMenuAction.Share -> {
             BookInfoMenuAction.Share -> {
                 val bookJson = GSON.toJson(book)
                 val bookJson = GSON.toJson(book)
@@ -1379,6 +1393,7 @@ class BookInfoViewModel(
             it.copy(
             it.copy(
                 book = currentBook?.toBookInfoBookUi(),
                 book = currentBook?.toBookInfoBookUi(),
                 hasChapters = currentChapterList.isNotEmpty(),
                 hasChapters = currentChapterList.isNotEmpty(),
+                tocLoadFailed = tocLoadFailed,
                 webFiles = currentWebFiles,
                 webFiles = currentWebFiles,
                 relatedBooks = currentRelatedBooks.toImmutableList(),
                 relatedBooks = currentRelatedBooks.toImmutableList(),
                 characters = currentCharacters.toImmutableList(),
                 characters = currentCharacters.toImmutableList(),
@@ -1664,6 +1679,7 @@ class BookInfoViewModel(
             latestChapterTitle = latestChapterTitle,
             latestChapterTitle = latestChapterTitle,
             totalChapterNum = totalChapterNum,
             totalChapterNum = totalChapterNum,
             durChapterIndex = durChapterIndex,
             durChapterIndex = durChapterIndex,
+            durChapterPos = durChapterPos,
             remark = remark,
             remark = remark,
             displayIntro = getDisplayIntro(),
             displayIntro = getDisplayIntro(),
         )
         )
@@ -1673,6 +1689,7 @@ class BookInfoViewModel(
         return BookInfoSourceUi(
         return BookInfoSourceUi(
             sourceUrl = bookSourceUrl,
             sourceUrl = bookSourceUrl,
             hasLogin = !loginUrl.isNullOrBlank(),
             hasLogin = !loginUrl.isNullOrBlank(),
+            hasCustomButton = customButton,
         )
         )
     }
     }
 
 

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

@@ -4,6 +4,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.consumeWindowInsets
 import androidx.compose.foundation.layout.consumeWindowInsets
@@ -16,7 +17,6 @@ import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.LazyRow
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.input.TextFieldLineLimits
 import androidx.compose.foundation.text.input.TextFieldLineLimits
 import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
@@ -32,7 +32,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
 import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.getValue
@@ -53,18 +52,20 @@ import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
-import io.legado.app.ui.config.themeConfig.ThemeConfig
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.theme.fadingEdge
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
 import io.legado.app.ui.widget.components.button.series.MediumOutlinedButton
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 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.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
+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.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
@@ -165,15 +166,18 @@ fun BookInfoEditContent(
                                 )
                                 )
                             )
                             )
                         },
                         },
-                        icon = Icons.Default.ImageSearch
+                        icon = Icons.Default.ImageSearch,
+                        contentDescription = stringResource(R.string.refresh_cover)
                     )
                     )
                     MediumOutlinedButton(
                     MediumOutlinedButton(
                         onClick = { selectCover.launch() },
                         onClick = { selectCover.launch() },
-                        icon = Icons.Default.FolderOpen
+                        icon = Icons.Default.FolderOpen,
+                        contentDescription = stringResource(R.string.select_folder)
                     )
                     )
                     MediumOutlinedButton(
                     MediumOutlinedButton(
                         onClick = { viewModel.resetCover() },
                         onClick = { viewModel.resetCover() },
-                        icon = Icons.Default.Replay
+                        icon = Icons.Default.Replay,
+                        contentDescription = stringResource(R.string.cover_reset)
                     )
                     )
                 }
                 }
                 Spacer(modifier = Modifier.height(4.dp))
                 Spacer(modifier = Modifier.height(4.dp))
@@ -196,17 +200,11 @@ fun BookInfoEditContent(
             onCheckedChange = { viewModel.onFixedTypeChange(it) }
             onCheckedChange = { viewModel.onFixedTypeChange(it) }
         )
         )
         Spacer(modifier = Modifier.height(16.dp))
         Spacer(modifier = Modifier.height(16.dp))
-        val bookInfoInputColor = ThemeConfig.bookInfoInputColor
-        val inputBackgroundColor = if (bookInfoInputColor != 0) {
-            Color(bookInfoInputColor)
-        } else {
-            Color.Unspecified
-        }
         AppTextField(
         AppTextField(
             value = uiState.name,
             value = uiState.name,
             onValueChange = { viewModel.onNameChange(it) },
             onValueChange = { viewModel.onNameChange(it) },
             label = stringResource(R.string.book_name),
             label = stringResource(R.string.book_name),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
             modifier = Modifier.fillMaxWidth()
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
@@ -214,7 +212,7 @@ fun BookInfoEditContent(
             value = uiState.author,
             value = uiState.author,
             onValueChange = { viewModel.onAuthorChange(it) },
             onValueChange = { viewModel.onAuthorChange(it) },
             label = stringResource(R.string.author),
             label = stringResource(R.string.author),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
             modifier = Modifier.fillMaxWidth()
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
@@ -222,7 +220,16 @@ fun BookInfoEditContent(
             value = uiState.coverUrl ?: "",
             value = uiState.coverUrl ?: "",
             onValueChange = { viewModel.onCoverUrlChange(it) },
             onValueChange = { viewModel.onCoverUrlChange(it) },
             label = stringResource(R.string.cover_url),
             label = stringResource(R.string.cover_url),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
+            modifier = Modifier.fillMaxWidth()
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+        AppTextField(
+            value = uiState.sourceKindList.joinToString(", "),
+            onValueChange = {},
+            readOnly = true,
+            label = stringResource(R.string.source_categories),
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
             modifier = Modifier.fillMaxWidth()
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
@@ -230,14 +237,14 @@ fun BookInfoEditContent(
             kindList = uiState.kindList,
             kindList = uiState.kindList,
             onKindListChange = { viewModel.onKindListChange(it) },
             onKindListChange = { viewModel.onKindListChange(it) },
             onReset = { viewModel.resetKinds() },
             onReset = { viewModel.resetKinds() },
-            backgroundColor = inputBackgroundColor
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
         AppTextField(
         AppTextField(
             value = uiState.intro ?: "",
             value = uiState.intro ?: "",
             onValueChange = { viewModel.onIntroChange(it) },
             onValueChange = { viewModel.onIntroChange(it) },
             label = stringResource(R.string.book_intro),
             label = stringResource(R.string.book_intro),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
             modifier = Modifier.fillMaxWidth()
         )
         )
         Spacer(modifier = Modifier.height(8.dp))
         Spacer(modifier = Modifier.height(8.dp))
@@ -245,7 +252,7 @@ fun BookInfoEditContent(
             value = uiState.remark ?: "",
             value = uiState.remark ?: "",
             onValueChange = { viewModel.onRemarkChange(it) },
             onValueChange = { viewModel.onRemarkChange(it) },
             label = stringResource(R.string.book_remark),
             label = stringResource(R.string.book_remark),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
             modifier = Modifier.fillMaxWidth()
         )
         )
     }
     }
@@ -269,13 +276,6 @@ fun BookTypeDropdown(
         textFieldState.setTextAndPlaceCursorAtEnd(selectedTypeLabel)
         textFieldState.setTextAndPlaceCursorAtEnd(selectedTypeLabel)
     }
     }
 
 
-    val bookInfoInputColor = ThemeConfig.bookInfoInputColor
-    val inputBackgroundColor = if (bookInfoInputColor != 0) {
-        Color(bookInfoInputColor)
-    } else {
-        Color.Unspecified
-    }
-
     ExposedDropdownMenuBox(
     ExposedDropdownMenuBox(
         modifier = Modifier.padding(horizontal = 8.dp),
         modifier = Modifier.padding(horizontal = 8.dp),
         expanded = expanded,
         expanded = expanded,
@@ -286,7 +286,7 @@ fun BookTypeDropdown(
             readOnly = true,
             readOnly = true,
             lineLimits = TextFieldLineLimits.SingleLine,
             lineLimits = TextFieldLineLimits.SingleLine,
             label = stringResource(R.string.book_type_label),
             label = stringResource(R.string.book_type_label),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             trailingIcon = {
             trailingIcon = {
                 ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
                 ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
             },
             },
@@ -328,65 +328,88 @@ fun KindEditor(
     val hapticFeedback = LocalHapticFeedback.current
     val hapticFeedback = LocalHapticFeedback.current
 
 
     val listState = rememberLazyListState()
     val listState = rememberLazyListState()
-    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+    fun moveKind(from: Int, to: Int) {
         val mutable = kindList.toMutableList()
         val mutable = kindList.toMutableList()
-        mutable.add(to.index, mutable.removeAt(from.index))
+        mutable.add(to, mutable.removeAt(from))
         onKindListChange(mutable)
         onKindListChange(mutable)
     }
     }
+    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+        moveKind(from.index, to.index)
+    }
 
 
-    Row(
-        modifier = Modifier.fillMaxWidth(),
-        verticalAlignment = Alignment.CenterVertically
+    Column(
+        modifier = Modifier.padding(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(4.dp)
     ) {
     ) {
-        LazyRow(
-            state = listState,
-            modifier = Modifier
-                .weight(1f)
-                .fadingEdge(listState),
-            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        AppText(
+            text = stringResource(R.string.my_tags)
+        )
+        Row(
+            modifier = Modifier.fillMaxWidth(),
             verticalAlignment = Alignment.CenterVertically
             verticalAlignment = Alignment.CenterVertically
         ) {
         ) {
-            items(kindList.size, key = { kindList[it] }) { index ->
-                val kind = kindList[index]
-                ReorderableItem(reorderableState, key = kind) { isDragging ->
-                    KindChip(
-                        text = kind,
-                        isDragging = isDragging,
-                        onClick = {
-                            editingIndex = index
-                            editText = kind
-                        },
-                        modifier = Modifier
-                            .longPressDraggableHandle(
-                                onDragStarted = {
-                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
-                                },
-                                onDragStopped = {
-                                    hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
-                                }
-                            )
-                            .animateItem()
-                    )
+            LazyRow(
+                state = listState,
+                modifier = Modifier
+                    .weight(1f)
+                    .fadingEdge(listState),
+                contentPadding = PaddingValues(vertical = 4.dp),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                items(kindList.size, key = { kindList[it] }) { index ->
+                    val kind = kindList[index]
+                    ReorderableItem(
+                        state = reorderableState,
+                        key = kind
+                    ) { isDragging ->
+                        KindChip(
+                            text = kind,
+                            isDragging = isDragging,
+                            onClick = {
+                                editingIndex = index
+                                editText = kind
+                            },
+                            modifier = Modifier
+                                .reorderAccessibility(
+                                    index = index,
+                                    itemCount = kindList.size,
+                                    onMove = ::moveKind,
+                                )
+                                .longPressDraggableHandle(
+                                    onDragStarted = {
+                                        hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
+                                    },
+                                    onDragStopped = {
+                                        hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
+                                    }
+                                )
+                                .animateItem()
+                        )
+                    }
                 }
                 }
             }
             }
+            Spacer(modifier = Modifier.width(8.dp))
+            MediumOutlinedButton(
+                onClick = {
+                    onReset()
+                },
+                icon = Icons.Default.Replay,
+                contentDescription = stringResource(R.string.reset)
+            )
+            Spacer(modifier = Modifier.width(8.dp))
+            MediumOutlinedButton(
+                onClick = {
+                    editingIndex = -1
+                    editText = ""
+                },
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.add)
+            )
         }
         }
-        Spacer(modifier = Modifier.width(8.dp))
-        MediumOutlinedButton(
-            onClick = {
-                onReset()
-            },
-            icon = Icons.Default.Replay
-        )
-        Spacer(modifier = Modifier.width(8.dp))
-        MediumOutlinedButton(
-            onClick = {
-                editingIndex = -1
-                editText = ""
-            },
-            icon = Icons.Default.Add
-        )
     }
     }
 
 
+
     if (editingIndex != null) {
     if (editingIndex != null) {
         val isAdding = editingIndex == -1
         val isAdding = editingIndex == -1
         AppAlertDialog(
         AppAlertDialog(
@@ -443,12 +466,13 @@ private fun KindChip(
     onClick: () -> Unit,
     onClick: () -> Unit,
     modifier: Modifier = Modifier
     modifier: Modifier = Modifier
 ) {
 ) {
-    Surface(
-        shape = RoundedCornerShape(8.dp),
-        color = LegadoTheme.colorScheme.surfaceContainer,
+    NormalCard(
+        cornerRadius = 8.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
         contentColor = LegadoTheme.colorScheme.onSurface,
         contentColor = LegadoTheme.colorScheme.onSurface,
-        shadowElevation = if (isDragging) 8.dp else 0.dp,
+        elevation = if (isDragging) 2.dp else 0.dp,
         modifier = modifier
         modifier = modifier
+            .padding(2.dp)
             .zIndex(if (isDragging) 1f else 0f)
             .zIndex(if (isDragging) 1f else 0f)
             .clickable(onClick = onClick)
             .clickable(onClick = onClick)
     ) {
     ) {

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

@@ -20,6 +20,7 @@ import io.legado.app.utils.FileUtils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.inputStream
 import io.legado.app.utils.inputStream
+import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -38,6 +39,7 @@ data class BookInfoEditUiState(
     val coverUrl: String? = null,
     val coverUrl: String? = null,
     val intro: String? = null,
     val intro: String? = null,
     val remark: String? = null,
     val remark: String? = null,
+    val sourceKindList: List<String> = emptyList(),
     val kindList: List<String> = emptyList(),
     val kindList: List<String> = emptyList(),
     val originalKindList: List<String> = emptyList(),
     val originalKindList: List<String> = emptyList(),
     val selectedType: BookInfoEditType = BookInfoEditType.TEXT,
     val selectedType: BookInfoEditType = BookInfoEditType.TEXT,
@@ -59,16 +61,17 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                     it.isAudio -> BookInfoEditType.AUDIO
                     it.isAudio -> BookInfoEditType.AUDIO
                     else -> BookInfoEditType.TEXT
                     else -> BookInfoEditType.TEXT
                 }
                 }
-                val kinds =
-                    it.kind?.split(",", "\n")?.filter { kind -> kind.isNotBlank() }.orEmpty()
+                val sourceKinds = it.kind?.splitNotBlank(",", "\n").orEmpty().distinct()
+                val customTags = it.customTag?.splitNotBlank(",", "\n").orEmpty().distinct()
                 _uiState.value = BookInfoEditUiState(
                 _uiState.value = BookInfoEditUiState(
                     name = it.name,
                     name = it.name,
                     author = it.author,
                     author = it.author,
                     coverUrl = it.getDisplayCover(),
                     coverUrl = it.getDisplayCover(),
                     intro = it.getDisplayIntro(),
                     intro = it.getDisplayIntro(),
                     remark = it.remark,
                     remark = it.remark,
-                    kindList = kinds,
-                    originalKindList = kinds,
+                    sourceKindList = sourceKinds,
+                    kindList = customTags,
+                    originalKindList = customTags,
                     selectedType = selectedType,
                     selectedType = selectedType,
                     fixedType = it.config.fixedType,
                     fixedType = it.config.fixedType,
                     book = it
                     book = it
@@ -102,7 +105,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
     }
     }
 
 
     fun onKindListChange(kindList: List<String>) {
     fun onKindListChange(kindList: List<String>) {
-        _uiState.value = _uiState.value.copy(kindList = kindList)
+        _uiState.value = _uiState.value.copy(kindList = kindList.distinct())
     }
     }
 
 
     fun onBookTypeChange(bookType: BookInfoEditType) {
     fun onBookTypeChange(bookType: BookInfoEditType) {
@@ -136,7 +139,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                 book.config.fixedType = currentState.fixedType
                 book.config.fixedType = currentState.fixedType
                 book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl
                 book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl
                 book.customIntro = if (currentState.intro == book.intro) null else currentState.intro
                 book.customIntro = if (currentState.intro == book.intro) null else currentState.intro
-                book.kind = currentState.kindList.joinToString(",")
+                book.customTag = currentState.kindList.joinToString(",").ifBlank { null }
                 BookHelp.updateCacheFolder(oldBook, book)
                 BookHelp.updateCacheFolder(oldBook, book)
 
 
                 if (ReadBook.book?.bookUrl == book.bookUrl) {
                 if (ReadBook.book?.bookUrl == book.bookUrl) {

+ 29 - 10
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
@@ -98,6 +99,7 @@ import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 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.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
@@ -295,7 +297,7 @@ private fun BookshelfManageScreen(
         }
         }
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
         val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
         val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
-        if (state.exportConfig.enableCustomExport) {
+        if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = dirPath
             customExportPath = dirPath
             customExportBook = book
             customExportBook = book
             customExportAllChapter = false
             customExportAllChapter = false
@@ -336,7 +338,7 @@ private fun BookshelfManageScreen(
         val path = ACache.get().getAsString(exportBookPathKey)
         val path = ACache.get().getAsString(exportBookPathKey)
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
             selectExportFolder(book.bookUrl)
             selectExportFolder(book.bookUrl)
-        } else if (state.exportConfig.enableCustomExport) {
+        } else if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = path
             customExportPath = path
             customExportBook = book
             customExportBook = book
             customExportAllChapter = false
             customExportAllChapter = false
@@ -478,7 +480,7 @@ private fun BookshelfManageScreen(
                 TopBarActionButton(
                 TopBarActionButton(
                     onClick = { showGroupMenu = true },
                     onClick = { showGroupMenu = true },
                     imageVector = AppIcons.Filter,
                     imageVector = AppIcons.Filter,
-                    contentDescription = null
+                    contentDescription = stringResource(R.string.a11y_group_filter)
                 )
                 )
                 RoundDropdownMenu(
                 RoundDropdownMenu(
                     expanded = showGroupMenu,
                     expanded = showGroupMenu,
@@ -656,7 +658,7 @@ private fun BookshelfManageScreen(
             ),
             ),
             verticalArrangement = Arrangement.spacedBy(8.dp)
             verticalArrangement = Arrangement.spacedBy(8.dp)
         ) {
         ) {
-            items(filteredBooks, key = { it.bookUrl }) { book ->
+            itemsIndexed(filteredBooks, key = { _, item -> item.bookUrl }) { index, book ->
                 val cacheCount = remember(renderVersion, book.bookUrl) {
                 val cacheCount = remember(renderVersion, book.bookUrl) {
                     viewModel.getCacheCount(book.bookUrl) ?: 0
                     viewModel.getCacheCount(book.bookUrl) ?: 0
                 }
                 }
@@ -687,6 +689,15 @@ private fun BookshelfManageScreen(
                     GlassCard(
                     GlassCard(
                         modifier = Modifier
                         modifier = Modifier
                             .fillMaxWidth()
                             .fillMaxWidth()
+                            .reorderAccessibility(
+                                index = index,
+                                itemCount = filteredBooks.size,
+                                enabled = canReorderBooks,
+                            ) { from, to ->
+                                viewModel.dispatch(
+                                    BookshelfManageScreenIntent.MoveBookOrder(from, to)
+                                )
+                            }
                             .then(
                             .then(
                                 if (canReorderBooks) {
                                 if (canReorderBooks) {
                                     Modifier.longPressDraggableHandle()
                                     Modifier.longPressDraggableHandle()
@@ -801,12 +812,12 @@ private fun BookshelfManageScreen(
                                             }
                                             }
                                         },
                                         },
                                         icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
                                         icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
-                                        contentDescription = "download"
+                                        contentDescription = stringResource(R.string.action_download)
                                     )
                                     )
                                     SmallTonalButton(
                                     SmallTonalButton(
                                         onClick = { exportBook(book) },
                                         onClick = { exportBook(book) },
                                         icon = Icons.Default.Upload,
                                         icon = Icons.Default.Upload,
-                                        contentDescription = "upload"
+                                        contentDescription = stringResource(R.string.upload_to_remote)
                                     )
                                     )
                                     SmallTonalButton(
                                     SmallTonalButton(
                                         onClick = {
                                         onClick = {
@@ -815,12 +826,12 @@ private fun BookshelfManageScreen(
                                             showGroupSelectSheet = true
                                             showGroupSelectSheet = true
                                         },
                                         },
                                         icon = Icons.Default.Bookmarks,
                                         icon = Icons.Default.Bookmarks,
-                                        contentDescription = "group"
+                                        contentDescription = stringResource(R.string.group)
                                     )
                                     )
                                     SmallTonalButton(
                                     SmallTonalButton(
                                         onClick = { moreMenuBookUrl = book.bookUrl },
                                         onClick = { moreMenuBookUrl = book.bookUrl },
                                         icon = Icons.Default.MoreVert,
                                         icon = Icons.Default.MoreVert,
-                                        contentDescription = "more"
+                                        contentDescription = stringResource(R.string.more_menu)
                                     )
                                     )
                                 }
                                 }
                             }
                             }
@@ -1349,6 +1360,13 @@ private fun BookSourcePickerSheet(
                     ReorderableSelectionItem(
                     ReorderableSelectionItem(
                         state = reorderableState,
                         state = reorderableState,
                         key = source.bookSourceUrl,
                         key = source.bookSourceUrl,
+                        reorderIndex = selectedSources.indexOf(source),
+                        reorderItemCount = selectedSources.size,
+                        onMoveItem = { from, to ->
+                            selectedSources = selectedSources.toMutableList().apply {
+                                move(from, to)
+                            }
+                        },
                         title = source.bookSourceName,
                         title = source.bookSourceName,
                         subtitle = source.bookSourceGroup,
                         subtitle = source.bookSourceGroup,
                         isSelected = true,
                         isSelected = true,
@@ -1526,7 +1544,8 @@ private fun BatchChangePreviewRow(
             ) {
             ) {
                 SmallTonalButton(
                 SmallTonalButton(
                     onClick = { onManualSearch(item.oldBook) },
                     onClick = { onManualSearch(item.oldBook) },
-                    icon = Icons.Default.Search
+                    icon = Icons.Default.Search,
+                    contentDescription = stringResource(R.string.search)
                 )
                 )
                 SmallTonalButton(
                 SmallTonalButton(
                     onClick = { onSkip(item.oldBook.bookUrl) },
                     onClick = { onSkip(item.oldBook.bookUrl) },
@@ -1634,7 +1653,7 @@ private fun OtherSourceOptionsSheet(
                         SmallTonalButton(
                         SmallTonalButton(
                             onClick = { onOpenBook(candidate.book) },
                             onClick = { onOpenBook(candidate.book) },
                             icon = Icons.Default.Info,
                             icon = Icons.Default.Info,
-                            contentDescription = null,
+                            contentDescription = stringResource(R.string.details),
                         )
                         )
                     },
                     },
                     containerColor = LegadoTheme.colorScheme.onSheetContent,
                     containerColor = LegadoTheme.colorScheme.onSheetContent,

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreenViewModel.kt

@@ -60,7 +60,10 @@ data class BookshelfManageScreenExportConfig(
     val exportCharset: String = "UTF-8",
     val exportCharset: String = "UTF-8",
     val bookExportFileName: String? = null,
     val bookExportFileName: String? = null,
     val episodeExportFileName: String = ""
     val episodeExportFileName: String = ""
-)
+) {
+    val isCustomEpubExportEnabled: Boolean
+        get() = enableCustomExport && exportType == 1
+}
 
 
 data class BookshelfManageScreenUiState(
 data class BookshelfManageScreenUiState(
     val groupId: Long = -1,
     val groupId: Long = -1,

+ 65 - 7
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaActivity.kt

@@ -75,6 +75,7 @@ import io.legado.app.ui.book.read.observeEyeProtectionEvents
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.browser.WebViewActivity
+import io.legado.app.ui.config.otherConfig.OtherConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.login.SourceLoginActivity
@@ -140,6 +141,8 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
     }
 
 
     private var justInitData: Boolean = false
     private var justInitData: Boolean = false
+    private var isExitActionRunning = false
+    private var isRestoredFromSavedState = false
     private var syncDialog: AlertDialog? = null
     private var syncDialog: AlertDialog? = null
     private val handler by lazy { buildMainHandler() }
     private val handler by lazy { buildMainHandler() }
     private val eyeProtectionScheduler by lazy {
     private val eyeProtectionScheduler by lazy {
@@ -199,6 +202,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
     }
 
 
     override fun onCreate(savedInstanceState: Bundle?) {
     override fun onCreate(savedInstanceState: Bundle?) {
+        isRestoredFromSavedState = savedInstanceState != null
         setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         val transform = MaterialContainerTransform().apply {
         val transform = MaterialContainerTransform().apply {
@@ -235,15 +239,61 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             GSON.fromJsonObject<MangaFooterConfig>(ReadMangaConfig.mangaFooterConfig).getOrNull()
             GSON.fromJsonObject<MangaFooterConfig>(ReadMangaConfig.mangaFooterConfig).getOrNull()
                 ?: MangaFooterConfig()
                 ?: MangaFooterConfig()
 
 
-        onBackPressedDispatcher.addCallback(this){
-            if (savedInstanceState != null || !ReadManga.inBookshelf) {
-                finish()
-            } else {
-                supportFinishAfterTransition()
+        onBackPressedDispatcher.addCallback(this) {
+            requestReaderExit()
+        }
+    }
+
+    private fun requestReaderExit() {
+        if (isExitActionRunning) return
+        val book = ReadManga.book
+        if (book == null || ReadManga.inBookshelf) {
+            finishReader()
+            return
+        }
+        if (!OtherConfig.showAddToShelfAlert) {
+            discardCurrentNotShelfBook()
+            return
+        }
+        alert(title = getString(R.string.add_to_bookshelf)) {
+            setCancelable(false)
+            setMessage(getString(R.string.check_add_bookshelf, book.name))
+            okButton {
+                addCurrentBookToBookshelf()
+            }
+            noButton {
+                discardCurrentNotShelfBook()
             }
             }
         }
         }
     }
     }
 
 
+    private fun addCurrentBookToBookshelf() {
+        isExitActionRunning = true
+        viewModel.addCurrentBookToBookshelf(
+            success = {
+                setResult(RESULT_OK)
+                finishReader()
+            },
+            onFailure = { isExitActionRunning = false }
+        )
+    }
+
+    private fun discardCurrentNotShelfBook() {
+        isExitActionRunning = true
+        viewModel.removeCurrentNotShelfBook(
+            success = ::finishReader,
+            onFailure = { isExitActionRunning = false }
+        )
+    }
+
+    private fun finishReader() {
+        if (ReadManga.inBookshelf && !isRestoredFromSavedState) {
+            supportFinishAfterTransition()
+        } else {
+            finish()
+        }
+    }
+
     override fun observeLiveBus() {
     override fun observeLiveBus() {
         observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
         observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
             mMangaFooterConfig = it
             mMangaFooterConfig = it
@@ -315,11 +365,11 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             }
             }
 
 
             override fun nextChapter() {
             override fun nextChapter() {
-                ReadManga.moveToNextChapter()
+                ReadManga.moveToNextChapter(toFirst = true)
             }
             }
 
 
             override fun prevChapter() {
             override fun prevChapter() {
-                ReadManga.moveToPrevChapter()
+                ReadManga.moveToPrevChapter(toFirst = true)
             }
             }
         }
         }
 
 
@@ -395,6 +445,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
 
     private fun upInfoBar(page: Any?) {
     private fun upInfoBar(page: Any?) {
         if (page !is MangaPage) {
         if (page !is MangaPage) {
+            binding.webtoonFrame.updateAccessibilityState(getString(R.string.loading))
             return
             return
         }
         }
         val chapterIndex = page.chapterIndex
         val chapterIndex = page.chapterIndex
@@ -402,6 +453,13 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
         val chapterPos = page.index
         val chapterPos = page.index
         val imageCount = page.imageCount
         val imageCount = page.imageCount
         val chapterName = page.mChapterName
         val chapterName = page.mChapterName
+        binding.webtoonFrame.updateAccessibilityState(
+            listOfNotNull(
+                chapterName.takeIf(String::isNotBlank),
+                getString(R.string.a11y_read_progress_page, chapterPos + 1, imageCount),
+                getString(R.string.a11y_read_progress_chapter, chapterIndex + 1, chapterSize),
+            ).joinToString(", ")
+        )
         mMangaFooterConfig.run {
         mMangaFooterConfig.run {
             mLabelBuilder.clear()
             mLabelBuilder.clear()
             binding.infobar.isGone = hideFooter
             binding.infobar.isGone = hideFooter

+ 41 - 9
app/src/main/java/io/legado/app/ui/book/manga/ReadMangaViewModel.kt

@@ -313,12 +313,23 @@ class ReadMangaViewModel(
         }
         }
     }
     }
 
 
-    fun removeFromBookshelf(success: (() -> Unit)?) {
+    fun removeCurrentNotShelfBook(
+        success: () -> Unit,
+        onFailure: () -> Unit = {},
+    ) {
         val book = ReadManga.book
         val book = ReadManga.book
+        val bookUrl = book?.bookUrl
         Coroutine.async {
         Coroutine.async {
             book?.delete()
             book?.delete()
         }.onSuccess {
         }.onSuccess {
-            success?.invoke()
+            if (ReadManga.book?.bookUrl == bookUrl) {
+                ReadManga.book = null
+            }
+            success()
+        }.onError {
+            AppLog.put("移除临时漫画书籍失败", it)
+            context.toastOnUi("移除临时漫画书籍失败")
+            onFailure()
         }
         }
     }
     }
 
 
@@ -393,15 +404,27 @@ class ReadMangaViewModel(
         }
         }
     }
     }
 
 
-    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+    fun addCurrentBookToBookshelf(
+        success: () -> Unit,
+        onFailure: () -> Unit = {},
+    ) {
+        val book = ReadManga.book ?: return onFailure()
         execute {
         execute {
-            book.removeType(BookType.notShelf)
-            if (book.order == 0) {
-                book.order = appDb.bookDao.minOrder - 1
-            }
+            val toc = appDb.bookChapterDao.getChapterList(book.bookUrl)
+            persistOnBookshelf(book, toc)
+            ReadManga.inBookshelf = true
+        }.onSuccess {
+            success()
+        }.onError {
+            AppLog.put("添加书籍到书架失败", it)
+            context.toastOnUi("添加书籍失败")
+            onFailure()
+        }
+    }
 
 
-            appDb.bookDao.insert(book)
-            appDb.bookChapterDao.insert(*toc.toTypedArray())
+    fun addToBookshelf(book: Book, toc: List<BookChapter>, success: (() -> Unit)? = null) {
+        execute {
+            persistOnBookshelf(book, toc)
         }.onSuccess {
         }.onSuccess {
             success?.invoke()
             success?.invoke()
         }.onError {
         }.onError {
@@ -409,4 +432,13 @@ class ReadMangaViewModel(
             context.toastOnUi("添加书籍失败")
             context.toastOnUi("添加书籍失败")
         }
         }
     }
     }
+
+    private suspend fun persistOnBookshelf(book: Book, toc: List<BookChapter>) {
+        book.removeType(BookType.notShelf)
+        if (book.order == 0) {
+            book.order = appDb.bookDao.minOrder - 1
+        }
+        appDb.bookDao.insert(book)
+        appDb.bookChapterDao.insert(*toc.toTypedArray())
+    }
 }
 }

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

@@ -36,7 +36,7 @@ open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Conte
     protected lateinit var mFlProgress: FrameLayout
     protected lateinit var mFlProgress: FrameLayout
     protected var mRetry: Button? = null
     protected var mRetry: Button? = null
 
 
-    //private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
+    private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
 
 
     fun initComponent(
     fun initComponent(
         loading: ProgressBar,
         loading: ProgressBar,
@@ -111,7 +111,7 @@ open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Conte
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                                 height = ViewGroup.LayoutParams.WRAP_CONTENT
                                 height = ViewGroup.LayoutParams.WRAP_CONTENT
                             }
                             }
-                            itemView.minimumHeight = 0
+                            itemView.minimumHeight = minHeight
                         } else {
                         } else {
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                                 height = ViewGroup.LayoutParams.MATCH_PARENT
                                 height = ViewGroup.LayoutParams.MATCH_PARENT
@@ -138,4 +138,4 @@ open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Conte
         }
         }
 
 
     }
     }
-}
+}

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

@@ -7,8 +7,12 @@ import android.util.AttributeSet
 import android.view.GestureDetector
 import android.view.GestureDetector
 import android.view.MotionEvent
 import android.view.MotionEvent
 import android.view.ScaleGestureDetector
 import android.view.ScaleGestureDetector
+import android.view.View
 import android.widget.FrameLayout
 import android.widget.FrameLayout
+import androidx.core.view.ViewCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
 import androidx.recyclerview.widget.RecyclerView
 import androidx.recyclerview.widget.RecyclerView
+import io.legado.app.R
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.utils.findCenterViewPosition
 import io.legado.app.utils.findCenterViewPosition
 
 
@@ -22,6 +26,22 @@ class WebtoonFrame : FrameLayout {
         defStyle
         defStyle
     )
     )
 
 
+    init {
+        contentDescription = context.getString(R.string.manga)
+        isFocusable = true
+        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+
+        ViewCompat.replaceAccessibilityAction(
+            this,
+            AccessibilityNodeInfoCompat.AccessibilityActionCompat.ACTION_CLICK,
+            context.getString(R.string.menu),
+        ) { _, _ -> performClick() }
+        addAccessibilityAction(R.string.prev_page) { it.prevPage() }
+        addAccessibilityAction(R.string.next_page) { it.nextPage() }
+        addAccessibilityAction(R.string.a11y_previous_chapter) { it.prevChapter() }
+        addAccessibilityAction(R.string.a11y_next_chapter) { it.nextChapter() }
+    }
+
     /** 长按回调,返回手指坐标 */
     /** 长按回调,返回手指坐标 */
     var longPressListener: ((centerPosition: Int) -> Unit)? = null
     var longPressListener: ((centerPosition: Int) -> Unit)? = null
 
 
@@ -125,6 +145,29 @@ class WebtoonFrame : FrameLayout {
         return super.dispatchTouchEvent(ev)
         return super.dispatchTouchEvent(ev)
     }
     }
 
 
+    override fun performClick(): Boolean {
+        val handler = actionHandler ?: return super.performClick()
+        super.performClick()
+        handler.showMenu()
+        return true
+    }
+
+    fun updateAccessibilityState(description: String) {
+        ViewCompat.setStateDescription(this, description)
+    }
+
+    private fun addAccessibilityAction(
+        labelRes: Int,
+        action: (ClickActionHandler) -> Unit,
+    ) {
+        ViewCompat.addAccessibilityAction(this, context.getString(labelRes)) { _, _ ->
+            actionHandler?.let {
+                action(it)
+                true
+            } ?: false
+        }
+    }
+
     private fun performClickAction(action: Int) {
     private fun performClickAction(action: Int) {
         when (action) {
         when (action) {
             -1 -> return
             -1 -> return

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

@@ -65,6 +65,9 @@ class WebtoonRecyclerView @JvmOverloads constructor(
         lastVisibleItemPosition =
         lastVisibleItemPosition =
             (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
             (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
         firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
         firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+        if (dx != 0 || dy != 0) {
+            notifyCenterPositionChanged(dx, dy)
+        }
     }
     }
 
 
     override fun onScrollStateChanged(state: Int) {
     override fun onScrollStateChanged(state: Int) {
@@ -83,13 +86,17 @@ class WebtoonRecyclerView @JvmOverloads constructor(
         offsetInWindow: IntArray?,
         offsetInWindow: IntArray?,
         type: Int
         type: Int
     ): Boolean {
     ): Boolean {
+        val position = findCenterViewPosition()
+        mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
+        return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
+    }
+
+    private fun notifyCenterPositionChanged(dx: Int, dy: Int) {
         val position = findCenterViewPosition()
         val position = findCenterViewPosition()
         if (position != NO_POSITION && position != mLastCenterViewPosition) {
         if (position != NO_POSITION && position != mLastCenterViewPosition) {
             mLastCenterViewPosition = position
             mLastCenterViewPosition = position
             mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
             mPreScrollListener?.onPreScrollListener(this, dx, dy, position)
         }
         }
-        mNestedPreScrollListener?.onPreScrollListener(this, dx, dy, position)
-        return super.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type)
     }
     }
 
 
     private fun getPositionX(positionX: Float): Float {
     private fun getPositionX(positionX: Float): Float {

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

@@ -79,13 +79,9 @@ private fun rememberReadBackgroundTheme(
     }
     }
 
 
     val sourceColor = seedColor ?: return null
     val sourceColor = seedColor ?: return null
-    val surfaceColor = when (background.type) {
-        0 -> background.value.toColorOrNull()
-        else -> ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color)
-    }
     return rememberReadThemeOverride(
     return rememberReadThemeOverride(
         seedColor = sourceColor,
         seedColor = sourceColor,
-        backgroundColor = surfaceColor,
+        backgroundColor = null,
         containerColor = null,
         containerColor = null,
         deriveDarkFromColor = false,
         deriveDarkFromColor = false,
         paletteStyle = paletteStyle,
         paletteStyle = paletteStyle,

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

@@ -83,6 +83,8 @@ data class ReadBookStyleConfig(
     val textColor: String = "#3E3D3B",
     val textColor: String = "#3E3D3B",
     val textColorNight: String = "#CCCCCC",
     val textColorNight: String = "#CCCCCC",
     val textColorEInk: String = "#000000",
     val textColorEInk: String = "#000000",
+    val textFont: String = "",
+    val titleFont: String = "",
     // Page anim
     // Page anim
     val pageAnim: Int = 0,
     val pageAnim: Int = 0,
     val pageAnimEInk: Int = 4,
     val pageAnimEInk: Int = 4,
@@ -311,8 +313,11 @@ data class ReadMenuConfig(
     val readMenuBorderWidth: Int = 0,
     val readMenuBorderWidth: Int = 0,
     val readMenuBorderColor: Int = 0,
     val readMenuBorderColor: Int = 0,
     val readMenuBorderColorNight: Int = 0,
     val readMenuBorderColorNight: Int = 0,
+    val readMenuTextColor: Int = 0,
+    val readMenuTextColorNight: Int = 0,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurColor: Int = 0,
     val readMenuBlurColor: Int = 0,
+    val readMenuBlurColorNight: Int = 0,
     val readMenuPaletteStyle: String = "",
     val readMenuPaletteStyle: String = "",
     val readMenuBlurRadius: Int = 24,
     val readMenuBlurRadius: Int = 24,
     val readMenuLensRadius: Float = 24f,
     val readMenuLensRadius: Float = 24f,
@@ -324,6 +329,7 @@ data class ReadMenuConfig(
     val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
     val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
     val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
     val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
     val readMenuIconStyle: Int = 0,
     val readMenuIconStyle: Int = 0,
+    val titleBarIconStyle: Int = 0,
     val readMenuIconShowText: Boolean = true,
     val readMenuIconShowText: Boolean = true,
     val readSliderMode: String = "0",
     val readSliderMode: String = "0",
     val titleBarCustomIcons: ImmutableMap<String, String> = persistentMapOf(),
     val titleBarCustomIcons: ImmutableMap<String, String> = persistentMapOf(),
@@ -476,6 +482,7 @@ sealed interface ReadBookIntent {
     data object OpenBookInfo : ReadBookIntent
     data object OpenBookInfo : ReadBookIntent
     data object OpenChapterList : ReadBookIntent
     data object OpenChapterList : ReadBookIntent
     data object OpenChapterUrl : ReadBookIntent
     data object OpenChapterUrl : ReadBookIntent
+    data class SourceCustomButton(val longClick: Boolean) : ReadBookIntent
     data object ToggleReadUrlInBrowser : ReadBookIntent
     data object ToggleReadUrlInBrowser : ReadBookIntent
 
 
     // Content edit
     // Content edit
@@ -684,7 +691,6 @@ sealed interface ReadBookIntent {
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
-    data class SaveReadAloudTtsTimer(val value: Int) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data class SetSpeechAnalysisMode(val value: String) : ReadBookIntent
     data class SetSpeechAnalysisMode(val value: String) : ReadBookIntent
@@ -791,6 +797,13 @@ sealed interface ReadBookEffect {
         val html: String? = null,
         val html: String? = null,
     ) : ReadBookEffect
     ) : ReadBookEffect
 
 
+    data class RunSourceCustomButton(
+        val event: String,
+        val source: BookSource,
+        val book: Book,
+        val chapter: BookChapter?,
+    ) : ReadBookEffect
+
     // Menu actions that need Activity
     // Menu actions that need Activity
     data object MenuChangeSource : ReadBookEffect
     data object MenuChangeSource : ReadBookEffect
     data object MenuBookChangeSource : ReadBookEffect
     data object MenuBookChangeSource : ReadBookEffect
@@ -810,7 +823,6 @@ sealed interface ReadBookEffect {
     data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect
     data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect
 
 
     // TTS
     // TTS
-    data class UpTtsAloudSpan(val chapterStart: Int) : ReadBookEffect
 
 
     // Dialogs (Activity-driven)
     // Dialogs (Activity-driven)
     data object ShowConfirmSkipToChapter : ReadBookEffect
     data object ShowConfirmSkipToChapter : ReadBookEffect
@@ -892,7 +904,6 @@ sealed interface ReadBookSheet {
     data object ParagraphIntervalConfig : ReadBookSheet
     data object ParagraphIntervalConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet
     data object PageKeyConfig : ReadBookSheet
     data object PageKeyConfig : ReadBookSheet
-    data object TextSelectMenuFilterConfig : ReadBookSheet
     data object InfoConfig : ReadBookSheet
     data object InfoConfig : ReadBookSheet
     data class Dict(val word: String) : ReadBookSheet
     data class Dict(val word: String) : ReadBookSheet
     data class Bookmark(
     data class Bookmark(
@@ -928,6 +939,7 @@ sealed interface ConfigUpdateAction {
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
+    data object RelayoutContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
     data object UpdateChapterStyle : ConfigUpdateAction
     data object UpdateChapterStyle : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction
@@ -1001,6 +1013,9 @@ sealed interface ConfigUpdate {
     data class TitleColor(val color: Int) : ConfigUpdate {
     data class TitleColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
     }
     }
+    data class TitleColorNight(val color: Int) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent, ConfigUpdateAction.InvalidateTextPage)
+    }
     data class TitleFont(val path: String) : ConfigUpdate {
     data class TitleFont(val path: String) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
     }
     }
@@ -1048,9 +1063,15 @@ sealed interface ConfigUpdate {
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     }
+    data class TipHeaderColorNight(val color: Int) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
     data class TipFooterColor(val color: Int) : ConfigUpdate {
     data class TipFooterColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     }
+    data class TipFooterColorNight(val color: Int) : ConfigUpdate {
+        override val actions = setOf(ConfigUpdateAction.UpdateStyle)
+    }
     data class TipDividerColor(val color: Int) : ConfigUpdate {
     data class TipDividerColor(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     }
@@ -1100,6 +1121,12 @@ sealed interface ConfigUpdate {
     data class MenuContainerColorNight(val color: Int) : ConfigUpdate {
     data class MenuContainerColorNight(val color: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
         override val actions = setOf(ConfigUpdateAction.UpdateBackground, ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.ReloadContent)
     }
     }
+    data class MenuTextColor(val color: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
+    data class MenuTextColorNight(val color: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class MenuColorMode(val value: Int) : ConfigUpdate {
     data class MenuColorMode(val value: Int) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
         override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
     }
@@ -1243,6 +1270,10 @@ sealed interface ConfigUpdate {
     data class MenuIconStyle(val value: Int) : ConfigUpdate {
     data class MenuIconStyle(val value: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
+
+    data class TitleBarIconStyle(val value: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class MenuIconItemsPerRow(val value: Int) : ConfigUpdate {
     data class MenuIconItemsPerRow(val value: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
@@ -1285,6 +1316,9 @@ sealed interface ConfigUpdate {
     data class MenuBlurColor(val color: Int) : ConfigUpdate {
     data class MenuBlurColor(val color: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
+    data class MenuBlurColorNight(val color: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class MenuPaletteStyle(val value: String) : ConfigUpdate {
     data class MenuPaletteStyle(val value: String) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
@@ -1415,9 +1449,6 @@ sealed interface ConfigUpdate {
     data class ShowSelectMenuIcon(val value: Boolean) : ConfigUpdate {
     data class ShowSelectMenuIcon(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }
-    data class TextSelectMenuFilter(val value: String) : ConfigUpdate {
-        override val actions = emptySet<ConfigUpdateAction>()
-    }
     data class ShowReadTitleAddition(val value: Boolean) : ConfigUpdate {
     data class ShowReadTitleAddition(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
         override val actions = emptySet<ConfigUpdateAction>()
     }
     }

+ 275 - 49
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -1,6 +1,8 @@
 package io.legado.app.ui.book.read
 package io.legado.app.ui.book.read
 
 
 import android.annotation.SuppressLint
 import android.annotation.SuppressLint
+import android.app.SearchManager
+import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.content.pm.ActivityInfo
 import android.os.Build
 import android.os.Build
 import android.view.Gravity
 import android.view.Gravity
@@ -12,6 +14,7 @@ import android.view.WindowInsets
 import android.view.WindowManager
 import android.view.WindowManager
 import androidx.appcompat.app.AppCompatActivity
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
 import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
 import androidx.core.view.HapticFeedbackConstantsCompat
 import androidx.core.view.HapticFeedbackConstantsCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.WindowInsetsCompat
 import androidx.core.view.doOnAttach
 import androidx.core.view.doOnAttach
@@ -51,10 +54,14 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.buildMainHandler
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.invisible
 import io.legado.app.utils.invisible
+import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.navigationBarGravity
 import io.legado.app.utils.navigationBarGravity
+import io.legado.app.utils.printOnDebug
+import io.legado.app.utils.sendToClip
 import io.legado.app.utils.setLightStatusBar
 import io.legado.app.utils.setLightStatusBar
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
 import io.legado.app.utils.setOnApplyWindowInsetsListenerCompat
+import io.legado.app.utils.share
 import io.legado.app.utils.sysBattery
 import io.legado.app.utils.sysBattery
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
 import io.legado.app.utils.throttle
@@ -62,7 +69,13 @@ import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.visible
 import io.legado.app.utils.visible
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
 
 
 /**
 /**
  * Encapsulates all the reader logic that used to be in ReadBookActivity.
  * Encapsulates all the reader logic that used to be in ReadBookActivity.
@@ -74,8 +87,7 @@ class ReadBookController(
 ) : ReadBookRouteHost,
 ) : ReadBookRouteHost,
     ReadBookInputHandler,
     ReadBookInputHandler,
     ReadView.CallBack,
     ReadView.CallBack,
-    ContentTextView.CallBack,
-    TextActionMenu.CallBack {
+    ContentTextView.CallBack {
 
 
     var refs: ReadBookViewRefs? = null
     var refs: ReadBookViewRefs? = null
 
 
@@ -91,6 +103,15 @@ class ReadBookController(
         pageChanged = false
         pageChanged = false
     }
     }
 
 
+    override fun previewBrightness(value: Int) {
+        val targetBrightness = value.coerceIn(0, 100) / 100f
+        val attributes = activity.window.attributes
+        if (attributes.screenBrightness != targetBrightness) {
+            attributes.screenBrightness = targetBrightness
+            activity.window.attributes = attributes
+        }
+    }
+
     // Callbacks to Activity for operations that require Activity-level state
     // Callbacks to Activity for operations that require Activity-level state
     var onScreenOffTimerStart: (() -> Unit)? = null
     var onScreenOffTimerStart: (() -> Unit)? = null
     var onStartContentLoadFinish: (() -> Unit)? = null
     var onStartContentLoadFinish: (() -> Unit)? = null
@@ -106,12 +127,16 @@ class ReadBookController(
     private val networkChangedListener by lazy { NetworkChangedListener(activity) }
     private val networkChangedListener by lazy { NetworkChangedListener(activity) }
     private val handler by lazy { buildMainHandler() }
     private val handler by lazy { buildMainHandler() }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
     private val screenOffRunnable by lazy { Runnable { keepScreenOn(false) } }
-    private val textActionMenu by lazy {
-        TextActionMenu(
-            context = activity,
-            callBack = this,
-            expandTextMenu = { viewModel.readPreferences.value.expandTextMenu }
-        )
+    private val _textMenuState = MutableStateFlow<TextMenuState?>(null)
+    val textMenuState = _textMenuState.asStateFlow()
+    private var textMenuRequestVersion = 0L
+    private val menuMutex = Mutex()
+    @Volatile
+    private var cachedActionMenuItems: List<ActionMenuItem>? = null
+
+    fun dismissTextActionMenu() {
+        textMenuRequestVersion++
+        _textMenuState.value = null
     }
     }
     private val popupAction by lazy { PopupAction(activity) }
     private val popupAction by lazy { PopupAction(activity) }
     private var screenTimeOut: Long = 0
     private var screenTimeOut: Long = 0
@@ -134,7 +159,7 @@ class ReadBookController(
     fun clearTts() {
     fun clearTts() {
         tts?.clearTts()
         tts?.clearTts()
         tts = null
         tts = null
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
         popupAction.dismiss()
         popupAction.dismiss()
         refs?.readView?.onDestroy()
         refs?.readView?.onDestroy()
         networkChangedListener.unRegister()
         networkChangedListener.unRegister()
@@ -174,6 +199,7 @@ class ReadBookController(
         }
         }
         newRefs.readView.upTime()
         newRefs.readView.upTime()
         newRefs.readView.upBattery(activity.sysBattery)
         newRefs.readView.upBattery(activity.sysBattery)
+        refreshActionMenuItems()
     }
     }
 
 
     fun onMenuVisibilityChanged(visible: Boolean) {
     fun onMenuVisibilityChanged(visible: Boolean) {
@@ -285,22 +311,30 @@ class ReadBookController(
 
 
     override fun showTextActionMenu() {
     override fun showTextActionMenu() {
         val r = refs ?: return
         val r = refs ?: return
-        val navigationBarHeight =
-            if (!ReadBookConfig.hideNavigationBar && activity.navigationBarGravity == Gravity.BOTTOM) {
-                r.navigationBar.height
-            } else {
-                0
-            }
-        textActionMenu.upMenu()
-        textActionMenu.show(
-            r.textMenuPosition,
-            r.root.height + navigationBarHeight,
-            r.textMenuPosition.x.toInt(),
-            r.textMenuPosition.y.toInt(),
-            r.cursorLeft.y.toInt() + r.cursorLeft.height,
-            r.cursorRight.x.toInt(),
-            r.cursorRight.y.toInt() + r.cursorRight.height
-        )
+        val text = selectedText
+        val startX = r.textMenuPosition.x.toInt()
+        val startTopY = r.textMenuPosition.y.toInt()
+        val startBottomY = r.cursorLeft.y.toInt() + r.cursorLeft.height
+        val endX = r.cursorRight.x.toInt()
+        val endBottomY = r.cursorRight.y.toInt() + r.cursorRight.height
+        val requestVersion = ++textMenuRequestVersion
+
+        activity.lifecycleScope.launch {
+            val items = getActionMenuItems()
+            val readView = refs?.readView
+            if (textMenuRequestVersion != requestVersion || readView?.isTextSelected != true) {
+                return@launch
+            }
+            _textMenuState.value = TextMenuState(
+                selectedText = text,
+                startX = startX,
+                startTopY = startTopY,
+                startBottomY = startBottomY,
+                endX = endX,
+                endBottomY = endBottomY,
+                items = items
+            )
+        }
     }
     }
 
 
     override fun autoPageStop() {
     override fun autoPageStop() {
@@ -415,7 +449,7 @@ class ReadBookController(
     override fun onCancelSelect() {
     override fun onCancelSelect() {
         refs?.cursorLeft?.invisible()
         refs?.cursorLeft?.invisible()
         refs?.cursorRight?.invisible()
         refs?.cursorRight?.invisible()
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
     }
     }
 
 
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
@@ -501,11 +535,18 @@ class ReadBookController(
 
 
     override fun onTouch(v: View?, event: MotionEvent?): Boolean {
     override fun onTouch(v: View?, event: MotionEvent?): Boolean {
         val r = refs ?: return false
         val r = refs ?: return false
-        if (v == null || event == null || !r.readView.isTextSelected) {
+        if (v == null || event == null) {
+            return false
+        }
+        val action = event.action
+        if (!r.readView.isTextSelected
+            && action != MotionEvent.ACTION_UP
+            && action != MotionEvent.ACTION_CANCEL
+        ) {
             return false
             return false
         }
         }
-        when (event.action) {
-            MotionEvent.ACTION_DOWN -> textActionMenu.dismiss()
+        when (action) {
+            MotionEvent.ACTION_DOWN -> dismissTextActionMenu()
             MotionEvent.ACTION_MOVE -> {
             MotionEvent.ACTION_MOVE -> {
                 when (v.id) {
                 when (v.id) {
                     R.id.cursor_left -> if (!r.readView.curPage.getReverseStartCursor()) {
                     R.id.cursor_left -> if (!r.readView.curPage.getReverseStartCursor()) {
@@ -534,17 +575,25 @@ class ReadBookController(
                 }
                 }
             }
             }
 
 
-            MotionEvent.ACTION_UP -> {
+            MotionEvent.ACTION_UP,
+            MotionEvent.ACTION_CANCEL -> {
+                if (action == MotionEvent.ACTION_UP) {
+                    v.performClick()
+                }
                 r.readView.curPage.resetReverseCursor()
                 r.readView.curPage.resetReverseCursor()
-                showTextActionMenu()
+                if (r.readView.isTextSelected) {
+                    showTextActionMenu()
+                } else {
+                    dismissTextActionMenu()
+                }
             }
             }
         }
         }
         return true
         return true
     }
     }
 
 
-    override val selectedText: String get() = refs?.readView?.getSelectText().orEmpty()
+    val selectedText: String get() = refs?.readView?.getSelectText().orEmpty()
 
 
-    override fun onMenuItemSelected(itemId: Int): Boolean {
+    fun onMenuItemSelected(itemId: Int): Boolean {
         when (itemId) {
         when (itemId) {
             R.id.menu_aloud -> {
             R.id.menu_aloud -> {
                 viewModel.onIntent(
                 viewModel.onIntent(
@@ -612,11 +661,140 @@ class ReadBookController(
         return false
         return false
     }
     }
 
 
-    override fun onMenuActionFinally() {
-        textActionMenu.dismiss()
+    fun onMenuActionFinally() {
+        dismissTextActionMenu()
         refs?.readView?.cancelSelect()
         refs?.readView?.cancelSelect()
     }
     }
 
 
+    suspend fun getActionMenuItems(): List<ActionMenuItem> = withContext(IO) {
+        menuMutex.withLock {
+            cachedActionMenuItems?.let { return@withContext it }
+
+            val items = mutableListOf<ActionMenuItem>()
+            items.add(ActionMenuItem(R.id.menu_copy, activity.getString(android.R.string.copy)))
+            items.add(ActionMenuItem(R.id.menu_share_str, activity.getString(R.string.share)))
+            items.add(ActionMenuItem(R.id.menu_browser, activity.getString(R.string.browser)))
+            items.add(ActionMenuItem(R.id.menu_aloud, activity.getString(R.string.read_aloud)))
+            items.add(ActionMenuItem(R.id.menu_bookmark, activity.getString(R.string.bookmark)))
+            items.add(ActionMenuItem(R.id.menu_dict, activity.getString(R.string.dict)))
+            items.add(ActionMenuItem(R.id.menu_replace, activity.getString(R.string.replace)))
+            items.add(ActionMenuItem(R.id.menu_edit, activity.getString(R.string.edit)))
+            items.add(ActionMenuItem(R.id.menu_ai_clean, activity.getString(R.string.ai_text_clean)))
+            items.add(ActionMenuItem(R.id.menu_ai_rewrite, activity.getString(R.string.ai_text_rewrite)))
+            items.add(ActionMenuItem(R.id.menu_search_content, activity.getString(R.string.search_content)))
+
+            val thirdPartyItems = mutableListOf<ActionMenuItem>()
+            runCatching {
+                val pm = activity.packageManager
+                val intent = Intent().setAction(Intent.ACTION_PROCESS_TEXT).setType("text/plain")
+                val resolveInfos = pm.queryIntentActivities(intent, 0)
+                for (resolveInfo in resolveInfos) {
+                    val processIntent = Intent()
+                        .setAction(Intent.ACTION_PROCESS_TEXT)
+                        .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false)
+                        .setClassName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name)
+                    
+                    val title = resolveInfo.loadLabel(pm).toString()
+                    val icon = if (ReadConfig.showSelectMenuIcon) {
+                        runCatching { resolveInfo.loadIcon(pm) }.getOrNull()
+                    } else null
+
+                    thirdPartyItems.add(ActionMenuItem(id = -1, title = title, iconDrawable = icon, intent = processIntent))
+                }
+            }
+
+            val allItems = items + thirdPartyItems
+            val configStr = ReadConfig.textSelectMenuConfig
+            val result = if (configStr.isEmpty()) {
+                allItems
+            } else {
+                runCatching {
+                    val savedConfigs = GSON.fromJsonObject<List<SelectionMenuConfigItem>>(configStr).getOrNull() ?: emptyList()
+                    val savedMap = savedConfigs.associateBy { it.id }
+
+                    val sortedItems = mutableListOf<ActionMenuItem>()
+                    for (saved in savedConfigs) {
+                        val found = allItems.find { it.uniqueId == saved.id }
+                        if (found != null) {
+                            val resolvedShowState = saved.showState ?: if (saved.enabled == false) 1 else 0
+                            sortedItems.add(found.copy(showState = resolvedShowState))
+                        }
+                    }
+                    for (item in allItems) {
+                        val uniqueId = item.uniqueId
+                        if (!savedMap.containsKey(uniqueId)) {
+                            sortedItems.add(item)
+                        }
+                    }
+                    sortedItems
+                }.getOrDefault(allItems)
+            }
+            cachedActionMenuItems = result
+            result
+        }
+    }
+
+    fun refreshActionMenuItems() {
+        activity.lifecycleScope.launch {
+            menuMutex.withLock {
+                cachedActionMenuItems = null
+            }
+            val menuItems = getActionMenuItems()
+            _textMenuState.value?.let { currentState ->
+                _textMenuState.value = currentState.copy(items = menuItems)
+            }
+        }
+    }
+
+    fun saveMenuConfig(items: List<ActionMenuItem>) {
+        val configs = items.map { item ->
+            SelectionMenuConfigItem(
+                id = item.uniqueId,
+                enabled = item.showState == 0,
+                showState = item.showState
+            )
+        }
+        ReadConfig.textSelectMenuConfig = GSON.toJson(configs)
+        refreshActionMenuItems()
+    }
+
+    fun onTextMenuItemClick(item: ActionMenuItem) {
+        if (item.intent != null) {
+            runCatching {
+                item.intent.putExtra(Intent.EXTRA_PROCESS_TEXT, selectedText)
+                activity.startActivity(item.intent)
+            }.onFailure { e ->
+                AppLog.put("执行文本菜单操作出错\n$e", e, true)
+            }
+        } else {
+            when (item.id) {
+                R.id.menu_copy -> activity.sendToClip(selectedText)
+                R.id.menu_share_str -> activity.share(selectedText)
+                R.id.menu_browser -> {
+                    runCatching {
+                        val intent = if (selectedText.isAbsUrl()) {
+                            Intent(Intent.ACTION_VIEW).apply {
+                                data = selectedText.toUri()
+                            }
+                        } else {
+                            Intent(Intent.ACTION_WEB_SEARCH).apply {
+                                putExtra(SearchManager.QUERY, selectedText)
+                            }
+                        }
+                        activity.startActivity(intent)
+                    }.onFailure { e ->
+                        e.printOnDebug()
+                        activity.toastOnUi(e.localizedMessage ?: "ERROR")
+                    }
+                }
+                else -> {
+                    onMenuItemSelected(item.id)
+                }
+            }
+        }
+        onMenuActionFinally()
+    }
+
     // ── Effect handling ───────────────────────────────────────────────
     // ── Effect handling ───────────────────────────────────────────────
 
 
     /**
     /**
@@ -638,6 +816,7 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
                         ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
                         ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage()
                         ConfigUpdateAction.InvalidateTextPage -> r.readView.invalidateTextPage()
@@ -704,20 +883,6 @@ class ReadBookController(
                 }
                 }
             }
             }
 
 
-            is ReadBookEffect.UpTtsAloudSpan -> {
-                activity.lifecycleScope.launch(IO) {
-                    if (BaseReadAloudService.isPlay()) {
-                        ReadBook.curTextChapter?.let { textChapter ->
-                            val pageIndex = ReadBook.durPageIndex
-                            val aloudSpanStart =
-                                effect.chapterStart - textChapter.getReadLength(pageIndex)
-                            textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
-                            refs?.readView?.upContent()
-                        }
-                    }
-                }
-            }
-
             is ReadBookEffect.RefreshBookContent -> {
             is ReadBookEffect.RefreshBookContent -> {
                 ReadBook.curTextChapter = null
                 ReadBook.curTextChapter = null
                 refs?.readView?.upContent()
                 refs?.readView?.upContent()
@@ -736,6 +901,7 @@ class ReadBookController(
             }
             }
 
 
             is ReadBookEffect.ContentLoadFinish -> {
             is ReadBookEffect.ContentLoadFinish -> {
+                viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
                 onStartContentLoadFinish?.invoke()
                 onStartContentLoadFinish?.invoke()
             }
             }
 
 
@@ -855,6 +1021,7 @@ class ReadBookController(
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.OpenWebView,
             is ReadBookEffect.OpenWebView,
+            is ReadBookEffect.RunSourceCustomButton,
             is ReadBookEffect.MenuSettingReplace,
             is ReadBookEffect.MenuSettingReplace,
             is ReadBookEffect.TextActionReplace,
             is ReadBookEffect.TextActionReplace,
             is ReadBookEffect.OpenReplaceEditor,
             is ReadBookEffect.OpenReplaceEditor,
@@ -887,6 +1054,17 @@ class ReadBookController(
         }
         }
     }
     }
 
 
+    fun updateReadAloudProgress(chapterStart: Int) {
+        if (!BaseReadAloudService.isPlay()) return
+        val textChapter = ReadBook.curTextChapter ?: return
+        if (textChapter.chapter.index != BaseReadAloudService.currentChapterIndex) return
+        val pageIndex = textChapter.getPageIndexByCharIndex(chapterStart)
+        if (pageIndex < 0 || pageIndex != ReadBook.durPageIndex) return
+        val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex)
+        textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
+        refs?.readView?.upContent(resetPageOffset = false)
+    }
+
     // ── Key handling ──
     // ── Key handling ──
 
 
     private fun toggleReadAloud() {
     private fun toggleReadAloud() {
@@ -1248,3 +1426,51 @@ class ReadBookController(
         }
         }
     }
     }
 }
 }
+
+data class TextMenuState(
+    val selectedText: String,
+    val startX: Int,
+    val startTopY: Int,
+    val startBottomY: Int,
+    val endX: Int,
+    val endBottomY: Int,
+    val items: List<ActionMenuItem>
+)
+
+data class ActionMenuItem(
+    val id: Int,
+    val title: String,
+    val iconDrawable: android.graphics.drawable.Drawable? = null,
+    val intent: Intent? = null,
+    val showState: Int = 0 // 0: 一级, 1: 折叠, 2: 隐藏
+) {
+    val enabled: Boolean
+        get() = showState == 0
+
+    val uniqueId: String
+        get() = if (intent != null) {
+            val comp = intent.component
+            if (comp != null) "${comp.packageName}/${comp.className}" else title
+        } else {
+            when (id) {
+                R.id.menu_copy -> "menu_copy"
+                R.id.menu_share_str -> "menu_share_str"
+                R.id.menu_browser -> "menu_browser"
+                R.id.menu_aloud -> "menu_aloud"
+                R.id.menu_bookmark -> "menu_bookmark"
+                R.id.menu_dict -> "menu_dict"
+                R.id.menu_replace -> "menu_replace"
+                R.id.menu_edit -> "menu_edit"
+                R.id.menu_ai_clean -> "menu_ai_clean"
+                R.id.menu_ai_rewrite -> "menu_ai_rewrite"
+                R.id.menu_search_content -> "menu_search_content"
+                else -> id.toString()
+            }
+        }
+}
+
+data class SelectionMenuConfigItem(
+    val id: String,
+    val enabled: Boolean? = null,
+    val showState: Int? = null
+)

Разница между файлами не показана из-за своего большого размера
+ 321 - 268
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt


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

@@ -18,6 +18,7 @@ import android.widget.FrameLayout
 import android.widget.ImageView
 import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalContext
@@ -41,13 +43,16 @@ import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentHelp
 import io.legado.app.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
+import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
 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.PageDirection
+import io.legado.app.ui.book.read.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.TocActivityResult
@@ -87,6 +92,8 @@ interface ReadBookRouteHost :
 
 
     fun closeReadBook()
     fun closeReadBook()
 
 
+    fun previewBrightness(value: Int)
+
     fun upSystemUiVisibility(
     fun upSystemUiVisibility(
         isInMultiWindow: Boolean,
         isInMultiWindow: Boolean,
         toolBarHide: Boolean,
         toolBarHide: Boolean,
@@ -121,6 +128,7 @@ fun ReadBookRouteScreen(
 ) {
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
+    val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
@@ -137,6 +145,14 @@ fun ReadBookRouteScreen(
         controller.onMenuVisibilityChanged(state.menuVisible)
         controller.onMenuVisibilityChanged(state.menuVisible)
     }
     }
 
 
+    LaunchedEffect(viewModel, controller, lifecycleOwner) {
+        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            viewModel.readAloudProgress.collect { chapterStart ->
+                chapterStart?.let(controller::updateReadAloudProgress)
+            }
+        }
+    }
+
     // ── ActivityResult Launchers ──────────────────────────────────────
     // ── ActivityResult Launchers ──────────────────────────────────────
 
 
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
@@ -303,8 +319,7 @@ fun ReadBookRouteScreen(
                             is ReadBookEffect.ShowLogin -> {
                             is ReadBookEffect.ShowLogin -> {
                                 context.startActivity(
                                 context.startActivity(
                                     Intent(context, SourceLoginActivity::class.java).apply {
                                     Intent(context, SourceLoginActivity::class.java).apply {
-                                        putExtra("type", "bookSource")
-                                        putExtra("key", effect.sourceUrl)
+                                        putExtra("bookType", BookType.text)
                                     }
                                     }
                                 )
                                 )
                             }
                             }
@@ -328,6 +343,18 @@ fun ReadBookRouteScreen(
                                     }
                                     }
                                 )
                                 )
                             }
                             }
+                            is ReadBookEffect.RunSourceCustomButton -> {
+                                (context as? AppCompatActivity)?.let { activity ->
+                                    SourceCallBack.callBackBtn(
+                                        activity,
+                                        effect.event,
+                                        effect.source,
+                                        effect.book,
+                                        effect.chapter,
+                                        BookType.text,
+                                    )
+                                }
+                            }
                             is ReadBookEffect.OpenSearchActivity -> {
                             is ReadBookEffect.OpenSearchActivity -> {
                                 onOpenSearch(effect.word, effect.bookUrl)
                                 onOpenSearch(effect.word, effect.bookUrl)
                             }
                             }
@@ -450,13 +477,26 @@ fun ReadBookRouteScreen(
                     SearchContentResult.resetReplayCache()
                     SearchContentResult.resetReplayCache()
                     return@collect
                     return@collect
                 }
                 }
-                viewModel.onIntent(
-                    ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
-                )
-                result.searchResults.getOrNull(result.index)?.let { searchResult ->
-                    viewModel.onIntent(
-                        ReadBookIntent.NavigateToSearchResult(searchResult, result.index)
-                    )
+                when (result) {
+                    is SearchContentResult.Clear -> {
+                        viewModel.onIntent(ReadBookIntent.SetSearchResults(emptyList(), 0, ""))
+                        viewModel.onIntent(ReadBookIntent.ExitSearch)
+                    }
+
+                    is SearchContentResult.Result -> {
+                        viewModel.onIntent(
+                            ReadBookIntent.SetSearchResults(
+                                result.searchResults,
+                                result.index,
+                                result.query
+                            )
+                        )
+                        result.searchResults.getOrNull(result.index)?.let { searchResult ->
+                            viewModel.onIntent(
+                                ReadBookIntent.NavigateToSearchResult(searchResult, result.index)
+                            )
+                        }
+                    }
                 }
                 }
                 SearchContentResult.resetReplayCache()
                 SearchContentResult.resetReplayCache()
             }
             }
@@ -465,6 +505,8 @@ fun ReadBookRouteScreen(
 
 
     // ── View layer + Compose UI ───────────────────────────────────────
     // ── View layer + Compose UI ───────────────────────────────────────
 
 
+    var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
+
     Box(Modifier.fillMaxSize()) {
     Box(Modifier.fillMaxSize()) {
         key(controller) {
         key(controller) {
             ReadBookViewLayer(
             ReadBookViewLayer(
@@ -485,6 +527,7 @@ fun ReadBookRouteScreen(
                 state = state,
                 state = state,
                 preferences = readPreferences,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onIntent = viewModel::onIntent,
+                onBrightnessPreview = host::previewBrightness,
                 backdrop = menuBackdrop,
                 backdrop = menuBackdrop,
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
             )
             )
@@ -514,8 +557,47 @@ fun ReadBookRouteScreen(
             }
             }
             ReadBookScreen(
             ReadBookScreen(
                 state = state,
                 state = state,
+                preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },
                 onBack = { controller.closeReadBook() },
+                onOpenTextSelectMenuConfig = {
+                    viewModel.onIntent(ReadBookIntent.DismissSheet)
+                    showSelectMenuConfigSheet = true
+                },
+            )
+            TextActionSelectionMenu(
+                menuState = textMenuState,
+                expandTextMenu = readPreferences.expandTextMenu,
+                onDismiss = { controller.dismissTextActionMenu() },
+                onItemClick = { item -> controller.onTextMenuItemClick(item) },
+                onOpenManage = {
+                    controller.dismissTextActionMenu()
+                    controller.refs?.readView?.cancelSelect()
+                    showSelectMenuConfigSheet = true
+                }
+            )
+            var configItems by remember { mutableStateOf<List<ActionMenuItem>>(emptyList()) }
+            LaunchedEffect(showSelectMenuConfigSheet) {
+                if (showSelectMenuConfigSheet) {
+                    configItems = controller.getActionMenuItems()
+                } else {
+                    configItems = emptyList()
+                }
+            }
+            TextSelectMenuConfigSheet(
+                show = showSelectMenuConfigSheet,
+                items = configItems,
+                expandTextMenu = readPreferences.expandTextMenu,
+                showSelectMenuIcon = readPreferences.showSelectMenuIcon,
+                onExpandTextMenuChange = {
+                    viewModel.onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ExpandTextMenu(it)))
+                },
+                onShowSelectMenuIconChange = {
+                    viewModel.onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ShowSelectMenuIcon(it)))
+                    controller.refreshActionMenuItems()
+                },
+                onDismissRequest = { showSelectMenuConfigSheet = false },
+                onSaved = { items -> controller.saveMenuConfig(items) }
             )
             )
         }
         }
     }
     }

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

@@ -12,8 +12,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.core.net.toUri
 import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.R
-import io.legado.app.data.repository.ReadSettingsRepository
-import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
 import io.legado.app.ui.book.read.sheet.AiTextRewriteSheet
 import io.legado.app.ui.book.read.sheet.AiTextRewriteSheet
@@ -43,7 +42,6 @@ import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerEffect
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerEffect
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerSheet
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerSheet
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
-import io.legado.app.ui.config.readConfig.TextSelectMenuFilterSheet
 import io.legado.app.ui.dict.DictSheet
 import io.legado.app.ui.dict.DictSheet
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.FontSelectSheet
@@ -70,8 +68,10 @@ import org.koin.compose.koinInject
 @Composable
 @Composable
 fun ReadBookScreen(
 fun ReadBookScreen(
     state: ReadBookUiState,
     state: ReadBookUiState,
+    preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
     onBack: () -> Unit,
+    onOpenTextSelectMenuConfig: () -> Unit,
 ) {
 ) {
     BackHandler {
     BackHandler {
         when {
         when {
@@ -199,24 +199,15 @@ fun ReadBookScreen(
         onDismissRequest = dismissSheet,
         onDismissRequest = dismissSheet,
         onIntent = onIntent,
         onIntent = onIntent,
     )
     )
-    val fontSelectReadSettings: ReadSettingsRepository = org.koin.compose.koinInject()
-    val fontSelectPreferences by fontSelectReadSettings.preferences.collectAsStateWithLifecycle(
-        initialValue = null
-    )
-    val fontSelectFolderState = remember(fontSelectPreferences) {
-        val pref = fontSelectPreferences
-        if (pref == null) {
-            FontFolderState.Loading
-        } else {
-            FontFolderState.Loaded(pref.fontFolder.takeIf { it.isNotEmpty() }?.toUri())
-        }
+    val fontSelectFolderState = remember(preferences.fontFolder) {
+        FontFolderState.Loaded(preferences.fontFolder.takeIf { it.isNotEmpty() }?.toUri())
     }
     }
     val fontSelectSystemTypefaces = stringArrayResource(R.array.system_typefaces)
     val fontSelectSystemTypefaces = stringArrayResource(R.array.system_typefaces)
     FontSelectSheet(
     FontSelectSheet(
         show = state.activeSheet is ReadBookSheet.FontSelect,
         show = state.activeSheet is ReadBookSheet.FontSelect,
         title = stringResource(R.string.select_font),
         title = stringResource(R.string.select_font),
         folderState = fontSelectFolderState,
         folderState = fontSelectFolderState,
-        selectedFontPath = ReadBookConfig.textFont,
+        selectedFontPath = state.styleConfig.textFont,
         onDismissRequest = dismissSheet,
         onDismissRequest = dismissSheet,
         onSelectFont = { onIntent(ReadBookIntent.SelectFont(it.uri.toString())) },
         onSelectFont = { onIntent(ReadBookIntent.SelectFont(it.uri.toString())) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectSystemTypeface(it)) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectSystemTypeface(it)) },
@@ -227,7 +218,7 @@ fun ReadBookScreen(
         show = state.activeSheet is ReadBookSheet.TitleFontSelect,
         show = state.activeSheet is ReadBookSheet.TitleFontSelect,
         title = stringResource(R.string.read_config_title_settings),
         title = stringResource(R.string.read_config_title_settings),
         folderState = fontSelectFolderState,
         folderState = fontSelectFolderState,
-        selectedFontPath = ReadBookConfig.titleFont,
+        selectedFontPath = state.styleConfig.titleFont,
         onDismissRequest = dismissSheet,
         onDismissRequest = dismissSheet,
         onSelectFont = { onIntent(ReadBookIntent.SelectTitleFont(it.uri.toString())) },
         onSelectFont = { onIntent(ReadBookIntent.SelectTitleFont(it.uri.toString())) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectTitleSystemTypeface(it)) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectTitleSystemTypeface(it)) },
@@ -296,10 +287,7 @@ fun ReadBookScreen(
             onIntent(ReadBookIntent.DismissSheet)
             onIntent(ReadBookIntent.DismissSheet)
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig))
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig))
         },
         },
-        onOpenTextSelectMenuFilterConfig = {
-            onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TextSelectMenuFilterConfig))
-        },
+        onOpenTextSelectMenuConfig = onOpenTextSelectMenuConfig,
     )
     )
     ReadAloudConfigSheet(
     ReadAloudConfigSheet(
         show = state.activeSheet is ReadBookSheet.ReadAloudConfig,
         show = state.activeSheet is ReadBookSheet.ReadAloudConfig,
@@ -482,15 +470,7 @@ fun ReadBookScreen(
             )
             )
         }
         }
 
 
-        is ReadBookSheet.TextSelectMenuFilterConfig -> {
-            TextSelectMenuFilterSheet(
-                show = true,
-                onDismissRequest = dismissSheet,
-                onFilterChanged = {
-                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TextSelectMenuFilter(it)))
-                }
-            )
-        }
+
 
 
         is ReadBookSheet.PageAnim -> {
         is ReadBookSheet.PageAnim -> {
             PageAnimConfigSheet(
             PageAnimConfigSheet(

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

@@ -14,7 +14,9 @@ import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
+import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 
 
@@ -46,13 +48,13 @@ fun ReadBookSearchBar(
                 onClick = {
                 onClick = {
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(-1))
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(-1))
                 },
                 },
-                tooltipText = "上一个结果",
+                tooltipText = stringResource(R.string.a11y_previous_result),
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
             ) {
             ) {
                 Icon(
                 Icon(
                     Icons.AutoMirrored.Filled.ArrowBack,
                     Icons.AutoMirrored.Filled.ArrowBack,
-                    contentDescription = "上一个结果",
+                    contentDescription = stringResource(R.string.a11y_previous_result),
                     modifier = Modifier.size(20.dp),
                     modifier = Modifier.size(20.dp),
                 )
                 )
             }
             }
@@ -71,13 +73,13 @@ fun ReadBookSearchBar(
                 onClick = {
                 onClick = {
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(1))
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(1))
                 },
                 },
-                tooltipText = "下一个结果",
+                tooltipText = stringResource(R.string.a11y_next_result),
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
             ) {
             ) {
                 Icon(
                 Icon(
                     Icons.AutoMirrored.Filled.ArrowForward,
                     Icons.AutoMirrored.Filled.ArrowForward,
-                    contentDescription = "下一个结果",
+                    contentDescription = stringResource(R.string.a11y_next_result),
                     modifier = Modifier.size(20.dp),
                     modifier = Modifier.size(20.dp),
                 )
                 )
             }
             }

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

@@ -44,6 +44,7 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.model.TextProcessAction
 import io.legado.app.domain.model.TextProcessAction
 import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.ReadingProgress
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
@@ -79,6 +80,7 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
+import io.legado.app.model.activeReadAloudProgress
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
@@ -189,6 +191,14 @@ class ReadBookViewModel(
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
     val effects = _effects.asSharedFlow()
 
 
+    private val _readAloudProgress = MutableStateFlow(
+        activeReadAloudProgress(
+            isPlaying = BaseReadAloudService.isPlay(),
+            currentProgress = BaseReadAloudService.currentProgress,
+        )
+    )
+    val readAloudProgress = _readAloudProgress.asStateFlow()
+
     private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
     private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
         _effects.subscriptionCount.first { it > 0 }
         _effects.subscriptionCount.first { it > 0 }
         _effects.emit(effect)
         _effects.emit(effect)
@@ -592,6 +602,7 @@ class ReadBookViewModel(
                 }
                 }
             }
             }
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()
+            is ReadBookIntent.SourceCustomButton -> runSourceCustomButton(intent.longClick)
             is ReadBookIntent.ToggleReadUrlInBrowser -> toggleReadUrlInBrowser()
             is ReadBookIntent.ToggleReadUrlInBrowser -> toggleReadUrlInBrowser()
             is ReadBookIntent.OpenContentEdit -> openContentEdit()
             is ReadBookIntent.OpenContentEdit -> openContentEdit()
             is ReadBookIntent.LoadContentEdit -> loadContentEdit()
             is ReadBookIntent.LoadContentEdit -> loadContentEdit()
@@ -717,8 +728,12 @@ class ReadBookViewModel(
 
 
             is ReadBookIntent.MenuEnableReplace -> {
             is ReadBookIntent.MenuEnableReplace -> {
                 ReadBook.book?.let {
                 ReadBook.book?.let {
-                    it.setUseReplaceRule(!it.getUseReplaceRule())
+                    val enabled = !it.getUseReplaceRule()
+                    it.setUseReplaceRule(enabled)
                     ReadBook.saveRead()
                     ReadBook.saveRead()
+                    _uiState.update { state ->
+                        state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
+                    }
                     replaceRuleChanged()
                     replaceRuleChanged()
                 }
                 }
             }
             }
@@ -874,7 +889,7 @@ class ReadBookViewModel(
                 }
                 }
             }
             }
 
 
-            is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart))
+            is ReadBookIntent.TtsProgress -> updateReadAloudProgress(intent.chapterStart)
             is ReadBookIntent.ReadAloudAction -> {
             is ReadBookIntent.ReadAloudAction -> {
                 if (_uiState.value.defaultReadAloudInterface ==
                 if (_uiState.value.defaultReadAloudInterface ==
                     ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
                     ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
@@ -1162,10 +1177,6 @@ class ReadBookViewModel(
             )
             )
             is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
             is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
             is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value)
             is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value)
-            is ReadBookIntent.SaveReadAloudTtsTimer -> {
-                viewModelScope.launch { readAloudSettingsRepository.saveTtsTimer(intent.value) }
-                _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.save_success)))
-            }
             is ReadBookIntent.SetReadAloudTtsFollowSys -> {
             is ReadBookIntent.SetReadAloudTtsFollowSys -> {
                 viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
                 viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
                 _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
                 _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
@@ -1451,9 +1462,8 @@ class ReadBookViewModel(
 
 
     private fun handleOnResume() {
     private fun handleOnResume() {
         // Read time tracking
         // Read time tracking
-        ReadBook.readStartTime = System.currentTimeMillis()
-        ReadBook.initReadTime()
-        ReadBook.startAutoSaveSession()
+        ReadBook.isUiActive = true
+        ReadBook.startReadSession()
 
 
         // Web book progress sync
         // Web book progress sync
         ReadBook.webBookProgress?.let {
         ReadBook.webBookProgress?.let {
@@ -1478,9 +1488,12 @@ class ReadBookViewModel(
         _effects.tryEmit(ReadBookEffect.StopAutoPage)
         _effects.tryEmit(ReadBookEffect.StopAutoPage)
 
 
         // Read time tracking
         // Read time tracking
+        ReadBook.isUiActive = false
         ReadBook.saveRead()
         ReadBook.saveRead()
-        ReadBook.stopAutoSaveSession()
-        ReadBook.commitReadSession()
+        if (!BaseReadAloudService.isPlay()) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         ReadBook.cancelPreDownloadTask()
         ReadBook.cancelPreDownloadTask()
 
 
         // View-layer
         // View-layer
@@ -1972,6 +1985,7 @@ class ReadBookViewModel(
                         9 -> ConfigUpdateAction.InvalidateTextPage
                         9 -> ConfigUpdateAction.InvalidateTextPage
                         10 -> ConfigUpdateAction.UpdateLayout
                         10 -> ConfigUpdateAction.UpdateLayout
                         11 -> ConfigUpdateAction.SubmitRenderTask
                         11 -> ConfigUpdateAction.SubmitRenderTask
+                        12 -> ConfigUpdateAction.RelayoutContent
                         else -> null
                         else -> null
                     }
                     }
                 }.toSet()
                 }.toSet()
@@ -2001,11 +2015,24 @@ class ReadBookViewModel(
                     (status == ReadAloudSessionStatus.Idle ||
                     (status == ReadAloudSessionStatus.Idle ||
                         status == ReadAloudSessionStatus.Paused)
                         status == ReadAloudSessionStatus.Paused)
                 ) {
                 ) {
+                    _readAloudProgress.value = null
                     _effects.tryEmit(ReadBookEffect.UpAloudState)
                     _effects.tryEmit(ReadBookEffect.UpAloudState)
                 }
                 }
+                if (previousStatus != ReadAloudSessionStatus.Paused &&
+                    status == ReadAloudSessionStatus.Paused
+                ) {
+                    _effects.tryEmit(
+                        ReadBookEffect.ShowToast(context.getString(R.string.read_aloud_pause))
+                    )
+                }
                 previousStatus = status
                 previousStatus = status
             }
             }
         }
         }
+        viewModelScope.launch {
+            eventFlow<Int>(EventBus.READ_ALOUD_DS).collect { minute ->
+                _uiState.update { it.copy(readAloudTtsTimer = minute.coerceAtLeast(0)) }
+            }
+        }
         viewModelScope.launch {
         viewModelScope.launch {
             @Suppress("UNCHECKED_CAST")
             @Suppress("UNCHECKED_CAST")
             eventFlow<List<SearchResult>>(EventBus.SEARCH_RESULT).collect { results ->
             eventFlow<List<SearchResult>>(EventBus.SEARCH_RESULT).collect { results ->
@@ -2034,11 +2061,17 @@ class ReadBookViewModel(
         }
         }
         viewModelScope.launch {
         viewModelScope.launch {
             eventFlowSticky<Int>(EventBus.TTS_PROGRESS).collect { chapterStart ->
             eventFlowSticky<Int>(EventBus.TTS_PROGRESS).collect { chapterStart ->
-                _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(chapterStart))
+                updateReadAloudProgress(chapterStart)
             }
             }
         }
         }
     }
     }
 
 
+    private fun updateReadAloudProgress(chapterStart: Int) {
+        if (BaseReadAloudService.isPlay() && chapterStart > 0) {
+            _readAloudProgress.value = chapterStart
+        }
+    }
+
     private fun collectReadPreferences() {
     private fun collectReadPreferences() {
         viewModelScope.launch {
         viewModelScope.launch {
             var previous: ReadPreferences? = null
             var previous: ReadPreferences? = null
@@ -2107,8 +2140,9 @@ class ReadBookViewModel(
     }
     }
 
 
     private fun setReadAloudTtsTimer(value: Int) {
     private fun setReadAloudTtsTimer(value: Int) {
-        val timer = value.coerceIn(0, 180)
+        val timer = PlaybackTimer.normalize(value)
         ReadAloud.setTimer(context, timer)
         ReadAloud.setTimer(context, timer)
+        viewModelScope.launch { readAloudSettingsRepository.setTtsTimer(timer) }
         _uiState.update { it.copy(readAloudTtsTimer = timer) }
         _uiState.update { it.copy(readAloudTtsTimer = timer) }
     }
     }
 
 
@@ -2155,6 +2189,8 @@ class ReadBookViewModel(
             textColor = dur.getTextColor(),
             textColor = dur.getTextColor(),
             textColorNight = dur.getTextColorNight(),
             textColorNight = dur.getTextColorNight(),
             textColorEInk = dur.getTextColorEInk(),
             textColorEInk = dur.getTextColorEInk(),
+            textFont = config.textFont,
+            titleFont = config.titleFont,
             pageAnim = actualConfig.getPageAnim(),
             pageAnim = actualConfig.getPageAnim(),
             pageAnimEInk = actualConfig.getPageAnimEInk(),
             pageAnimEInk = actualConfig.getPageAnimEInk(),
             shareLayout = config.shareLayout,
             shareLayout = config.shareLayout,
@@ -2203,8 +2239,11 @@ class ReadBookViewModel(
                 readMenuBorderWidth = ReadBookConfig.readMenuBorderWidth,
                 readMenuBorderWidth = ReadBookConfig.readMenuBorderWidth,
                 readMenuBorderColor = ReadBookConfig.readMenuBorderColor,
                 readMenuBorderColor = ReadBookConfig.readMenuBorderColor,
                 readMenuBorderColorNight = ReadBookConfig.readMenuBorderColorNight,
                 readMenuBorderColorNight = ReadBookConfig.readMenuBorderColorNight,
+                readMenuTextColor = ReadBookConfig.readMenuTextColor,
+                readMenuTextColorNight = ReadBookConfig.readMenuTextColorNight,
                 readMenuBlurAlpha = ReadBookConfig.readMenuBlurAlpha,
                 readMenuBlurAlpha = ReadBookConfig.readMenuBlurAlpha,
                 readMenuBlurColor = ReadBookConfig.readMenuBlurColor,
                 readMenuBlurColor = ReadBookConfig.readMenuBlurColor,
+                readMenuBlurColorNight = ReadBookConfig.readMenuBlurColorNight,
                 readMenuPaletteStyle = ReadBookConfig.readMenuPaletteStyle,
                 readMenuPaletteStyle = ReadBookConfig.readMenuPaletteStyle,
                 readMenuBlurRadius = ReadBookConfig.readMenuBlurRadius,
                 readMenuBlurRadius = ReadBookConfig.readMenuBlurRadius,
                 readMenuLensRadius = ReadBookConfig.readMenuLensRadius,
                 readMenuLensRadius = ReadBookConfig.readMenuLensRadius,
@@ -2216,6 +2255,7 @@ class ReadBookViewModel(
                 readMenuTopBarBlurStyle = ReadBookConfig.readMenuTopBarBlurStyle,
                 readMenuTopBarBlurStyle = ReadBookConfig.readMenuTopBarBlurStyle,
                 readMenuBottomBarBlurStyle = ReadBookConfig.readMenuBottomBarBlurStyle,
                 readMenuBottomBarBlurStyle = ReadBookConfig.readMenuBottomBarBlurStyle,
                 readMenuIconStyle = ReadBookConfig.readMenuIconStyle,
                 readMenuIconStyle = ReadBookConfig.readMenuIconStyle,
+                titleBarIconStyle = ReadBookConfig.titleBarIconStyle,
                 readMenuIconShowText = ReadBookConfig.readMenuIconShowText,
                 readMenuIconShowText = ReadBookConfig.readMenuIconShowText,
                 readSliderMode = ReadBookConfig.readSliderMode,
                 readSliderMode = ReadBookConfig.readSliderMode,
                 titleBarCustomIcons = ReadBookConfig.titleBarCustomIcons.toImmutableMap(),
                 titleBarCustomIcons = ReadBookConfig.titleBarCustomIcons.toImmutableMap(),
@@ -4528,6 +4568,9 @@ class ReadBookViewModel(
         ReadBook.book?.let {
         ReadBook.book?.let {
             it.setUseReplaceRule(enabled)
             it.setUseReplaceRule(enabled)
             ReadBook.saveRead()
             ReadBook.saveRead()
+            _uiState.update { state ->
+                state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
+            }
             replaceRuleChanged()
             replaceRuleChanged()
         }
         }
     }
     }
@@ -4566,6 +4609,13 @@ class ReadBookViewModel(
                         route,
                         route,
                     ),
                     ),
                 ),
                 ),
+                readAloudTtsTimer = if (
+                    route == ReadBookMenuRoute.ReadAloud && BaseReadAloudService.isRun
+                ) {
+                    BaseReadAloudService.timeMinute.coerceAtLeast(0)
+                } else {
+                    it.readAloudTtsTimer
+                },
             )
             )
         }
         }
     }
     }
@@ -4594,6 +4644,7 @@ class ReadBookViewModel(
             is ConfigUpdate.TitleTopSpacing -> ReadBookConfig.titleTopSpacing = update.value
             is ConfigUpdate.TitleTopSpacing -> ReadBookConfig.titleTopSpacing = update.value
             is ConfigUpdate.TitleBottomSpacing -> ReadBookConfig.titleBottomSpacing = update.value
             is ConfigUpdate.TitleBottomSpacing -> ReadBookConfig.titleBottomSpacing = update.value
             is ConfigUpdate.TitleColor -> ReadBookConfig.titleColor = update.color
             is ConfigUpdate.TitleColor -> ReadBookConfig.titleColor = update.color
+            is ConfigUpdate.TitleColorNight -> ReadBookConfig.titleColorNight = update.color
             is ConfigUpdate.TitleFont -> ReadBookConfig.titleFont = update.path
             is ConfigUpdate.TitleFont -> ReadBookConfig.titleFont = update.path
             is ConfigUpdate.TitleSegType -> ReadBookConfig.titleSegType = update.value
             is ConfigUpdate.TitleSegType -> ReadBookConfig.titleSegType = update.value
             is ConfigUpdate.TitleSegDistance -> ReadBookConfig.titleSegDistance = update.value
             is ConfigUpdate.TitleSegDistance -> ReadBookConfig.titleSegDistance = update.value
@@ -4611,7 +4662,9 @@ class ReadBookViewModel(
             is ConfigUpdate.HeaderFont -> ReadBookConfig.headerFont = update.path
             is ConfigUpdate.HeaderFont -> ReadBookConfig.headerFont = update.path
             is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value
             is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value
             is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color
             is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color
+            is ConfigUpdate.TipHeaderColorNight -> ReadBookConfig.tipHeaderColorNight = update.color
             is ConfigUpdate.TipFooterColor -> ReadBookConfig.tipFooterColor = update.color
             is ConfigUpdate.TipFooterColor -> ReadBookConfig.tipFooterColor = update.color
+            is ConfigUpdate.TipFooterColorNight -> ReadBookConfig.tipFooterColorNight = update.color
             is ConfigUpdate.TipDividerColor -> ReadBookConfig.tipDividerColor = update.color
             is ConfigUpdate.TipDividerColor -> ReadBookConfig.tipDividerColor = update.color
 
 
             // --- Layout / style ---
             // --- Layout / style ---
@@ -4672,6 +4725,24 @@ class ReadBookViewModel(
                 }
                 }
                 postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
                 postEvent(EventBus.UPDATE_READ_ACTION_BAR, true)
             }
             }
+            is ConfigUpdate.MenuTextColor -> {
+                ReadBookConfig.readMenuTextColor = update.color
+                viewModelScope.launch {
+                    readSettingsRepository.setReadMenuTextColor(update.color)
+                }
+                _uiState.update {
+                    it.copy(menuConfig = it.menuConfig.copy(readMenuTextColor = update.color))
+                }
+            }
+            is ConfigUpdate.MenuTextColorNight -> {
+                ReadBookConfig.readMenuTextColorNight = update.color
+                viewModelScope.launch {
+                    readSettingsRepository.setReadMenuTextColorNight(update.color)
+                }
+                _uiState.update {
+                    it.copy(menuConfig = it.menuConfig.copy(readMenuTextColorNight = update.color))
+                }
+            }
             is ConfigUpdate.MenuColorMode -> {
             is ConfigUpdate.MenuColorMode -> {
                 val value = update.value.coerceIn(0, 1)
                 val value = update.value.coerceIn(0, 1)
                 ReadBookConfig.readMenuColorMode = value
                 ReadBookConfig.readMenuColorMode = value
@@ -4774,6 +4845,14 @@ class ReadBookViewModel(
                 }
                 }
                 _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconStyle = value)) }
                 _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuIconStyle = value)) }
             }
             }
+            is ConfigUpdate.TitleBarIconStyle -> {
+                val value = update.value.coerceIn(0, 2)
+                ReadBookConfig.titleBarIconStyle = value
+                viewModelScope.launch {
+                    readSettingsRepository.setTitleBarIconStyle(value)
+                }
+                _uiState.update { it.copy(menuConfig = it.menuConfig.copy(titleBarIconStyle = value)) }
+            }
             is ConfigUpdate.MenuIconItemsPerRow -> {
             is ConfigUpdate.MenuIconItemsPerRow -> {
                 val value = update.value.coerceIn(2, 8)
                 val value = update.value.coerceIn(2, 8)
                 ReadBookConfig.readMenuIconItemsPerRow = value
                 ReadBookConfig.readMenuIconItemsPerRow = value
@@ -4934,6 +5013,13 @@ class ReadBookViewModel(
                 }
                 }
                 _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBlurColor = update.color)) }
                 _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBlurColor = update.color)) }
             }
             }
+            is ConfigUpdate.MenuBlurColorNight -> {
+                ReadBookConfig.readMenuBlurColorNight = update.color
+                viewModelScope.launch {
+                    readSettingsRepository.setReadMenuBlurColorNight(update.color)
+                }
+                _uiState.update { it.copy(menuConfig = it.menuConfig.copy(readMenuBlurColorNight = update.color)) }
+            }
             is ConfigUpdate.MenuPaletteStyle -> {
             is ConfigUpdate.MenuPaletteStyle -> {
                 ReadBookConfig.readMenuPaletteStyle = update.value
                 ReadBookConfig.readMenuPaletteStyle = update.value
                 viewModelScope.launch {
                 viewModelScope.launch {
@@ -5221,12 +5307,6 @@ class ReadBookViewModel(
                     readSettingsRepository.setShowSelectMenuIcon(update.value)
                     readSettingsRepository.setShowSelectMenuIcon(update.value)
                 }
                 }
             }
             }
-            is ConfigUpdate.TextSelectMenuFilter -> {
-                ReadConfig.textSelectMenuFilter = update.value
-                viewModelScope.launch {
-                    readSettingsRepository.setTextSelectMenuFilter(update.value)
-                }
-            }
             is ConfigUpdate.ShowReadTitleAddition -> {
             is ConfigUpdate.ShowReadTitleAddition -> {
                 ReadConfig.showReadTitleAddition = update.value
                 ReadConfig.showReadTitleAddition = update.value
                 viewModelScope.launch {
                 viewModelScope.launch {
@@ -5815,8 +5895,10 @@ class ReadBookViewModel(
 
 
     private fun toggleTranslation() {
     private fun toggleTranslation() {
         val book = ReadBook.book ?: return
         val book = ReadBook.book ?: return
-        book.setTranslationMode(!book.getTranslationMode())
+        val enabled = !book.getTranslationMode()
+        book.setTranslationMode(enabled)
         book.save()
         book.save()
+        _uiState.update { it.copy(translationMode = enabled) }
         ReadBook.loadContent(false)
         ReadBook.loadContent(false)
     }
     }
 
 
@@ -5871,6 +5953,24 @@ class ReadBookViewModel(
         }
         }
     }
     }
 
 
+    private fun runSourceCustomButton(longClick: Boolean) {
+        val source = ReadBook.bookSource?.takeIf { it.customButton } ?: return
+        val book = ReadBook.book ?: return
+        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex)
+        _effects.tryEmit(
+            ReadBookEffect.RunSourceCustomButton(
+                event = if (longClick) {
+                    SourceCallBack.LONG_CLICK_CUSTOM_BUTTON
+                } else {
+                    SourceCallBack.CLICK_CUSTOM_BUTTON
+                },
+                source = source,
+                book = book,
+                chapter = chapter,
+            )
+        )
+    }
+
     private fun toggleReadUrlInBrowser() {
     private fun toggleReadUrlInBrowser() {
         viewModelScope.launch {
         viewModelScope.launch {
             val current = localPreferencesRepository
             val current = localPreferencesRepository

+ 0 - 305
app/src/main/java/io/legado/app/ui/book/read/TextActionMenu.kt

@@ -1,305 +0,0 @@
-package io.legado.app.ui.book.read
-
-import android.annotation.SuppressLint
-import android.app.SearchManager
-import android.content.Context
-import android.content.Intent
-import android.content.pm.ResolveInfo
-import android.view.Gravity
-import android.view.LayoutInflater
-import android.view.Menu
-import android.view.View
-import android.view.ViewGroup
-import android.widget.PopupWindow
-import androidx.appcompat.view.SupportMenuInflater
-import androidx.appcompat.view.menu.MenuBuilder
-import androidx.appcompat.view.menu.MenuItemImpl
-import androidx.core.net.toUri
-import androidx.core.view.isVisible
-import io.legado.app.R
-import io.legado.app.base.adapter.ItemViewHolder
-import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.AppLog
-import io.legado.app.databinding.ItemTextBinding
-import io.legado.app.databinding.PopupActionMenuBinding
-import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.dpToPx
-import io.legado.app.utils.gone
-import io.legado.app.utils.isAbsUrl
-import io.legado.app.utils.printOnDebug
-import io.legado.app.utils.sendToClip
-import io.legado.app.utils.share
-import io.legado.app.utils.toastOnUi
-import io.legado.app.utils.visible
-
-@SuppressLint("RestrictedApi")
-class TextActionMenu(
-    private val context: Context,
-    private val callBack: CallBack,
-    private val expandTextMenu: () -> Boolean
-) :
-    PopupWindow(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) {
-
-    private val binding = PopupActionMenuBinding.inflate(LayoutInflater.from(context))
-    private val adapter = Adapter(context).apply {
-        setHasStableIds(true)
-    }
-
-    private var menuItems: List<MenuItemImpl> = emptyList()
-    private val visibleMenuItems = arrayListOf<MenuItemImpl>()
-    private val moreMenuItems = arrayListOf<MenuItemImpl>()
-    private val myMenu = MenuBuilder(context)
-
-    init {
-        @SuppressLint("InflateParams")
-        contentView = binding.root
-
-        isTouchable = true
-        isOutsideTouchable = false
-        isFocusable = false
-        animationStyle = R.style.TextActionMenuAnimation
-
-        SupportMenuInflater(context).inflate(R.menu.content_select_action, myMenu)
-        binding.recyclerView.adapter = adapter
-        binding.recyclerViewMore.adapter = adapter
-        setOnDismissListener {
-            if (!expandTextMenu()) {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
-                binding.recyclerViewMore.gone()
-                adapter.setItems(visibleMenuItems)
-                binding.recyclerView.visible()
-            }
-        }
-        binding.ivMenuMore.setOnClickListener {
-            if (binding.recyclerView.isVisible) {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_arrow_back)
-                adapter.setItems(moreMenuItems)
-                binding.recyclerView.gone()
-                binding.recyclerViewMore.visible()
-            } else {
-                binding.ivMenuMore.setImageResource(R.drawable.ic_more_vert)
-                binding.recyclerViewMore.gone()
-                adapter.setItems(visibleMenuItems)
-                binding.recyclerView.visible()
-            }
-        }
-        upMenu()
-    }
-
-    fun upMenu() {
-        val otherMenu = MenuBuilder(context)
-        onInitializeMenu(otherMenu)
-        menuItems = myMenu.visibleItems + otherMenu.visibleItems
-        visibleMenuItems.clear()
-        moreMenuItems.clear()
-        if (menuItems.size > 6) {
-            visibleMenuItems.addAll(menuItems.subList(0, 6))
-            moreMenuItems.addAll(menuItems.subList(6, menuItems.size))
-        } else {
-            visibleMenuItems.addAll(menuItems)
-        }
-
-        if (expandTextMenu()) {
-            adapter.setItems(menuItems)
-            binding.ivMenuMore.gone()
-        } else {
-            adapter.setItems(visibleMenuItems)
-            binding.ivMenuMore.visible()
-        }
-    }
-
-    fun show(
-        view: View,
-        windowHeight: Int,
-        startX: Int,
-        startTopY: Int,
-        startBottomY: Int,
-        endX: Int,
-        endBottomY: Int
-    ) {
-        if (expandTextMenu()) {
-            when {
-                startTopY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.BOTTOM or Gravity.START, startX, windowHeight - startTopY
-                    )
-                }
-
-                endBottomY - startBottomY > 500 -> {
-                    showAtLocation(view, Gravity.TOP or Gravity.START, startX, startBottomY)
-                }
-
-                else -> {
-                    showAtLocation(view, Gravity.TOP or Gravity.START, endX, endBottomY)
-                }
-            }
-        } else {
-            contentView.measure(
-                View.MeasureSpec.UNSPECIFIED,
-                View.MeasureSpec.UNSPECIFIED,
-            )
-            val popupHeight = contentView.measuredHeight
-            when {
-                startBottomY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, startX, startTopY - popupHeight
-                    )
-                }
-
-                endBottomY - startBottomY > 500 -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, startX, startBottomY
-                    )
-                }
-
-                else -> {
-                    showAtLocation(
-                        view, Gravity.TOP or Gravity.START, endX, endBottomY
-                    )
-                }
-            }
-        }
-    }
-
-    inner class Adapter(context: Context) :
-        RecyclerAdapter<MenuItemImpl, ItemTextBinding>(context) {
-
-        override fun getItemId(position: Int): Long {
-            return position.toLong()
-        }
-
-        override fun getViewBinding(parent: ViewGroup): ItemTextBinding {
-            return ItemTextBinding.inflate(inflater, parent, false)
-        }
-
-        override fun convert(
-            holder: ItemViewHolder,
-            binding: ItemTextBinding,
-            item: MenuItemImpl,
-            payloads: MutableList<Any>
-        ) {
-            with(binding) {
-                textView.text = item.title
-                val icon = item.icon
-                if (icon != null && ReadConfig.showSelectMenuIcon) {
-                    val size = 18.dpToPx()
-                    icon.setBounds(0, 0, size, size)
-                    textView.setCompoundDrawables(icon, null, null, null)
-                    textView.compoundDrawablePadding = 6.dpToPx()
-                } else {
-                    textView.setCompoundDrawables(null, null, null, null)
-                }
-            }
-        }
-
-        override fun registerListener(holder: ItemViewHolder, binding: ItemTextBinding) {
-            holder.itemView.setOnClickListener {
-                getItem(holder.layoutPosition)?.let {
-                    if (!callBack.onMenuItemSelected(it.itemId)) {
-                        onMenuItemSelected(it)
-                    }
-                }
-                callBack.onMenuActionFinally()
-            }
-            holder.itemView.setOnLongClickListener {
-                if (ReadConfig.contentSelectSpeakMod == 0) {
-                    ReadConfig.contentSelectSpeakMod = 1
-                    context.toastOnUi("切换为从选择的地方开始一直朗读")
-                } else {
-                    ReadConfig.contentSelectSpeakMod = 0
-                    context.toastOnUi("切换为朗读选择内容")
-                }
-                true
-            }
-        }
-    }
-
-    private fun onMenuItemSelected(item: MenuItemImpl) {
-        when (item.itemId) {
-            R.id.menu_copy -> context.sendToClip(callBack.selectedText)
-            R.id.menu_share_str -> context.share(callBack.selectedText)
-            R.id.menu_browser -> {
-                kotlin.runCatching {
-                    val intent = if (callBack.selectedText.isAbsUrl()) {
-                        Intent(Intent.ACTION_VIEW).apply {
-                            data = callBack.selectedText.toUri()
-                        }
-                    } else {
-                        Intent(Intent.ACTION_WEB_SEARCH).apply {
-                            putExtra(SearchManager.QUERY, callBack.selectedText)
-                        }
-                    }
-                    context.startActivity(intent)
-                }.onFailure {
-                    it.printOnDebug()
-                    context.toastOnUi(it.localizedMessage ?: "ERROR")
-                }
-            }
-
-            else -> item.intent?.let {
-                kotlin.runCatching {
-                    it.putExtra(Intent.EXTRA_PROCESS_TEXT, callBack.selectedText)
-                    context.startActivity(it)
-                }.onFailure { e ->
-                    AppLog.put("执行文本菜单操作出错\n$e", e, true)
-                }
-            }
-        }
-    }
-
-    private fun createProcessTextIntent(): Intent {
-        return Intent()
-            .setAction(Intent.ACTION_PROCESS_TEXT)
-            .setType("text/plain")
-    }
-
-    private fun getSupportedActivities(): List<ResolveInfo> {
-        return context.packageManager
-            .queryIntentActivities(createProcessTextIntent(), 0)
-    }
-
-    private fun createProcessTextIntentForResolveInfo(info: ResolveInfo): Intent {
-        return createProcessTextIntent()
-            .putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, false)
-            .setClassName(info.activityInfo.packageName, info.activityInfo.name)
-    }
-
-    /**
-     * Start with a menu Item order value that is high enough
-     * so that your "PROCESS_TEXT" menu items appear after the
-     * standard selection menu items like Cut, Copy, Paste.
-     */
-    private fun onInitializeMenu(menu: Menu) {
-        kotlin.runCatching {
-            var menuItemOrder = 100
-            val pm = context.packageManager
-            val filterSet = ReadConfig.textSelectMenuFilter.split(",").filter { it.isNotEmpty() }.toSet()
-            for (resolveInfo in getSupportedActivities()) {
-                val componentName = "${resolveInfo.activityInfo.packageName}/${resolveInfo.activityInfo.name}"
-                if (filterSet.contains(componentName)) {
-                    continue
-                }
-                val menuItem = menu.add(
-                    Menu.NONE, Menu.NONE,
-                    menuItemOrder++, resolveInfo.loadLabel(pm)
-                )
-                menuItem.intent = createProcessTextIntentForResolveInfo(resolveInfo)
-                if (ReadConfig.showSelectMenuIcon) {
-                    menuItem.icon = kotlin.runCatching {
-                        resolveInfo.loadIcon(pm)
-                    }.getOrNull()
-                }
-            }
-        }.onFailure {
-            context.toastOnUi("获取文字操作菜单出错:${it.localizedMessage}")
-        }
-    }
-
-    interface CallBack {
-        val selectedText: String
-
-        fun onMenuItemSelected(itemId: Int): Boolean
-
-        fun onMenuActionFinally()
-    }
-}

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

@@ -0,0 +1,618 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.MutableTransitionState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.clickable
+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.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+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.graphics.TransformOrigin
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionOnScreen
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.platform.LocalWindowInfo
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.text.AppText
+import kotlin.math.roundToInt
+
+@Composable
+fun TextActionSelectionMenu(
+    menuState: TextMenuState?,
+    expandTextMenu: Boolean,
+    onDismiss: () -> Unit,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onOpenManage: () -> Unit
+) {
+
+    var retainedMenuState by remember { mutableStateOf(menuState) }
+    SideEffect {
+        if (menuState != null) retainedMenuState = menuState
+    }
+    val visibilityState = remember { MutableTransitionState(false) }
+    visibilityState.targetState = menuState != null
+    val displayedMenuState = menuState ?: retainedMenuState ?: return
+    if (!visibilityState.currentState && !visibilityState.targetState) return
+
+    val localDensity = LocalDensity.current
+    val containerSize = LocalWindowInfo.current.containerSize
+    val maxMenuWidth = with(localDensity){ containerSize.width.toDp() } - 32.dp
+    val menuShadowPadding = 12.dp
+    val menuCardMaxWidth = maxMenuWidth - menuShadowPadding * 2
+    var showMoreMenu by remember { mutableStateOf(false) }
+    var moreMenuAnchor by remember { mutableStateOf<IntRect?>(null) }
+    val view = LocalView.current
+    val windowLocation = IntArray(2).also(view::getLocationOnScreen)
+    val moreVisibilityState = remember { MutableTransitionState(false) }
+    moreVisibilityState.targetState = showMoreMenu
+    LaunchedEffect(menuState) {
+        if (menuState == null) showMoreMenu = false
+    }
+    val draftItems = displayedMenuState.items
+
+    val primaryItems = remember(draftItems) { draftItems.filter { it.showState == 0 } }
+    val collapsedItems = remember(draftItems) { draftItems.filter { it.showState == 1 } }
+    val activeMultiItems = remember(draftItems) { draftItems.filter { it.showState == 0 || it.showState == 1 } }
+
+    val density = localDensity.density
+    val shadowPaddingPx = with(localDensity) { menuShadowPadding.roundToPx() }
+    val positionProvider = remember(displayedMenuState, density, shadowPaddingPx) {
+        TextMenuPositionProvider(
+            density = density,
+            startX = displayedMenuState.startX,
+            startTopY = displayedMenuState.startTopY,
+            startBottomY = displayedMenuState.startBottomY,
+            endX = displayedMenuState.endX,
+            endBottomY = displayedMenuState.endBottomY,
+            shadowPadding = shadowPaddingPx,
+        )
+    }
+
+    Popup(
+        popupPositionProvider = positionProvider,
+        onDismissRequest = {
+            showMoreMenu = false
+            onDismiss()
+        },
+        properties = PopupProperties(
+            focusable = false,
+            dismissOnBackPress = !showMoreMenu,
+            dismissOnClickOutside = !showMoreMenu,
+        )
+    ) {
+
+        AnimatedVisibility(
+            visibleState = visibilityState,
+            enter = fadeIn(animationSpec = tween(360)) + scaleIn(
+                initialScale = 0.94f,
+                animationSpec = tween(400),
+            ),
+            exit = fadeOut(animationSpec = tween(320)) + scaleOut(
+                targetScale = 0.96f,
+                animationSpec = tween(280),
+            ),
+        ) {
+            if (expandTextMenu) {
+                Box(modifier = Modifier.padding(menuShadowPadding)) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 12.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        MultiLineMenuView(
+                            items = activeMultiItems,
+                            onItemClick = onItemClick,
+                            onManageClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
+                    }
+                }
+            } else {
+                val quickMenuScale by animateFloatAsState(
+                    targetValue = if (showMoreMenu) 0.96f else 1f,
+                    animationSpec = tween(durationMillis = 400),
+                    label = "quickMenuScale"
+                )
+                val quickMenuAlpha by animateFloatAsState(
+                    targetValue = if (showMoreMenu) 0.82f else 1f,
+                    animationSpec = tween(durationMillis = 360),
+                    label = "quickMenuAlpha"
+                )
+
+                Box(
+                    modifier = Modifier
+                        .graphicsLayer {
+                            scaleX = quickMenuScale
+                            scaleY = quickMenuScale
+                            alpha = quickMenuAlpha
+                            transformOrigin = TransformOrigin(1f, 0.5f)
+                        }
+                        .padding(menuShadowPadding),
+                ) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 6.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        QuickMenuView(
+                            items = primaryItems,
+                            hasMore = collapsedItems.isNotEmpty(),
+                            onItemClick = onItemClick,
+                            onMoreClick = { showMoreMenu = true },
+                            onMoreAnchorChanged = { moreMenuAnchor = it },
+                            onSettingsClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    val anchor = moreMenuAnchor
+    if (!expandTextMenu && anchor != null &&
+        (moreVisibilityState.currentState || moreVisibilityState.targetState)
+    ) {
+        val morePositionProvider = remember(anchor, density, windowLocation[0], windowLocation[1]) {
+            MoreMenuPositionProvider(
+                density = density,
+                anchorOnScreen = anchor,
+                windowXOnScreen = windowLocation[0],
+                windowYOnScreen = windowLocation[1],
+                shadowPadding = shadowPaddingPx,
+            )
+        }
+        Popup(
+            popupPositionProvider = morePositionProvider,
+            onDismissRequest = {
+                showMoreMenu = false
+                onDismiss()
+            },
+            properties = PopupProperties(
+                focusable = false,
+                dismissOnBackPress = true,
+                dismissOnClickOutside = true,
+            )
+        ) {
+            AnimatedVisibility(
+                visibleState = moreVisibilityState,
+                enter = fadeIn(animationSpec = tween(360)) + scaleIn(
+                    initialScale = 0.94f,
+                    transformOrigin = TransformOrigin(1f, 0.5f),
+                    animationSpec = tween(400),
+                ),
+                exit = fadeOut(animationSpec = tween(240)) + scaleOut(
+                    targetScale = 0.96f,
+                    transformOrigin = TransformOrigin(1f, 0.5f),
+                    animationSpec = tween(280),
+                ),
+            ) {
+                Box(
+                    modifier = Modifier.padding(menuShadowPadding),
+                ) {
+                    NormalCard(
+                        modifier = Modifier.widthIn(max = menuCardMaxWidth),
+                        containerColor = LegadoTheme.colorScheme.surfaceBright,
+                        elevation = 6.dp,
+                        cornerRadius = 12.dp,
+                    ) {
+                        MoreMenuView(
+                            items = collapsedItems,
+                            onItemClick = onItemClick,
+                            onBack = { showMoreMenu = false },
+                            onManageClick = {
+                                onDismiss()
+                                onOpenManage()
+                            }
+                        )
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun MultiLineMenuView(
+    items: List<ActionMenuItem>,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onManageClick: () -> Unit
+) {
+    FlowRow(
+        modifier = Modifier
+            .heightIn(max = 300.dp)
+            .verticalScroll(rememberScrollState()),
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+        verticalArrangement = Arrangement.spacedBy(0.dp)
+    ) {
+        items.forEach { item ->
+            QuickMenuItem(
+                item = item,
+                onClick = { onItemClick(item) },
+                verticalPadding = 12.dp,
+            )
+        }
+
+        Row(
+            modifier = Modifier
+                .clickable(onClick = onManageClick)
+                .padding(horizontal = 16.dp, vertical = 12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Icon(
+                imageVector = Icons.Default.Settings,
+                contentDescription = stringResource(R.string.edit_menu_items),
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(16.dp)
+            )
+            Spacer(modifier = Modifier.width(6.dp))
+            AppText(
+                text = stringResource(R.string.edit_menu_items),
+                style = LegadoTheme.typography.labelSmallEmphasized
+            )
+        }
+    }
+}
+
+@Composable
+private fun QuickMenuView(
+    items: List<ActionMenuItem>,
+    hasMore: Boolean,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onMoreClick: () -> Unit,
+    onMoreAnchorChanged: (IntRect) -> Unit,
+    onSettingsClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier.wrapContentWidth(),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        LazyRow(
+            modifier = Modifier.weight(1f, fill = false),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            itemsIndexed(
+                items = items,
+                key = { _, item -> item.uniqueId },
+            ) { index, item ->
+                QuickMenuItem(
+                    item = item,
+                    onClick = { onItemClick(item) },
+                    startPadding = if (index == 0) 16.dp else 10.dp,
+                    endPadding = 8.dp,
+                )
+            }
+        }
+        if (items.isNotEmpty()) {
+            VerticalDivider(
+                color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+                modifier = Modifier
+                    .height(20.dp)
+                    .width(1.dp)
+            )
+        }
+        Box(
+            modifier = Modifier
+                .onGloballyPositioned { coordinates ->
+                    val position = coordinates.positionOnScreen()
+                    onMoreAnchorChanged(
+                        IntRect(
+                            left = position.x.roundToInt(),
+                            top = position.y.roundToInt(),
+                            right = position.x.roundToInt() + coordinates.size.width,
+                            bottom = position.y.roundToInt() + coordinates.size.height,
+                        )
+                    )
+                }
+                .clickable(onClick = if (hasMore) onMoreClick else onSettingsClick)
+                .padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 12.dp),
+            contentAlignment = Alignment.Center
+        ) {
+            Icon(
+                imageVector = if (hasMore) Icons.Default.MoreVert else Icons.Default.Settings,
+                contentDescription = stringResource(if (hasMore) R.string.more_menu else R.string.setting),
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(16.dp)
+            )
+        }
+    }
+}
+
+@Composable
+private fun QuickMenuItem(
+    item: ActionMenuItem,
+    onClick: () -> Unit,
+    startPadding: androidx.compose.ui.unit.Dp = 16.dp,
+    endPadding: androidx.compose.ui.unit.Dp = 16.dp,
+    verticalPadding: androidx.compose.ui.unit.Dp = 12.dp,
+) {
+    Row(
+        modifier = Modifier
+            .clickable(onClick = onClick)
+            .padding(
+                start = startPadding,
+                end = endPadding,
+                top = verticalPadding,
+                bottom = verticalPadding,
+            ),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (item.iconDrawable != null) {
+            AsyncImage(
+                model = item.iconDrawable,
+                contentDescription = item.title,
+                modifier = Modifier
+                    .size(16.dp)
+            )
+
+            Spacer(modifier = Modifier.width(12.dp))
+        }
+        AppText(
+            text = item.title,
+            style = LegadoTheme.typography.labelMedium,
+            maxLines = 1
+        )
+    }
+}
+
+@Composable
+private fun MoreMenuView(
+    items: List<ActionMenuItem>,
+    onItemClick: (ActionMenuItem) -> Unit,
+    onBack: () -> Unit,
+    onManageClick: () -> Unit
+) {
+    val scrollState = rememberScrollState()
+
+    Column(
+        modifier = Modifier
+            .width(IntrinsicSize.Max)
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable(onClick = onBack)
+                .padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 8.dp),
+            contentAlignment = Alignment.CenterStart
+        ) {
+            Icon(
+                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+                contentDescription = stringResource(R.string.back),
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(20.dp)
+            )
+        }
+
+        HorizontalDivider(
+            color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+            modifier = Modifier
+                .height(1.dp)
+                .fillMaxWidth(0.8f)
+                .align(Alignment.CenterHorizontally)
+        )
+
+        Column(
+            modifier = Modifier
+                .heightIn(max = 240.dp)
+                .verticalScroll(scrollState)
+                .fillMaxWidth()
+        ) {
+            items.forEach { item ->
+                MoreMenuItem(item = item, onClick = { onItemClick(item) })
+            }
+        }
+
+        HorizontalDivider(
+            color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
+            modifier = Modifier
+                .height(1.dp)
+                .fillMaxWidth(0.8f)
+                .align(Alignment.CenterHorizontally)
+        )
+
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable(onClick = onManageClick)
+                .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Icon(
+                imageVector = Icons.Default.Settings,
+                contentDescription = stringResource(R.string.edit_menu_items),
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier
+                    .size(20.dp)
+            )
+            Spacer(modifier = Modifier.width(12.dp))
+            AppText(
+                text = stringResource(R.string.edit_menu_items),
+                style = LegadoTheme.typography.labelMedium
+            )
+        }
+    }
+}
+
+@Composable
+private fun MoreMenuItem(
+    item: ActionMenuItem,
+    onClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clickable(onClick = onClick)
+            .padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        if (item.iconDrawable != null) {
+            AsyncImage(
+                model = item.iconDrawable,
+                contentDescription = item.title,
+                modifier = Modifier
+                    .size(20.dp)
+            )
+            Spacer(modifier = Modifier.width(12.dp))
+        }
+        AppText(
+            text = item.title,
+            style = LegadoTheme.typography.labelMedium
+        )
+    }
+}
+
+
+private class MoreMenuPositionProvider(
+    private val density: Float,
+    private val anchorOnScreen: IntRect,
+    private val windowXOnScreen: Int,
+    private val windowYOnScreen: Int,
+    private val shadowPadding: Int,
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize,
+    ): IntOffset {
+        val margin = (16 * density).roundToInt()
+        val anchor = IntRect(
+            left = anchorOnScreen.left - windowXOnScreen,
+            top = anchorOnScreen.top - windowYOnScreen,
+            right = anchorOnScreen.right - windowXOnScreen,
+            bottom = anchorOnScreen.bottom - windowYOnScreen,
+        )
+        val x = (anchor.right - popupContentSize.width + shadowPadding).coerceIn(
+            margin - shadowPadding,
+            (windowSize.width - popupContentSize.width - margin + shadowPadding)
+                .coerceAtLeast(margin - shadowPadding),
+        )
+        val spaceBelow = windowSize.height - anchor.top - margin + shadowPadding
+        val spaceAbove = anchor.bottom - margin + shadowPadding
+        val preferredY = if (
+            popupContentSize.height <= spaceBelow || spaceBelow >= spaceAbove
+        ) {
+            // The first row covers the three-dot button and the menu expands downward.
+            anchor.top - shadowPadding
+        } else {
+            // Near the bottom edge, expand upward with the last row over the button.
+            anchor.bottom - popupContentSize.height + shadowPadding
+        }
+        val y = preferredY.coerceIn(
+            margin - shadowPadding,
+            (windowSize.height - popupContentSize.height - margin + shadowPadding)
+                .coerceAtLeast(margin - shadowPadding),
+        )
+        return IntOffset(x, y)
+    }
+}
+
+private class TextMenuPositionProvider(
+    private val density: Float,
+    private val startX: Int,
+    private val startTopY: Int,
+    private val startBottomY: Int,
+    private val endX: Int,
+    private val endBottomY: Int,
+    private val shadowPadding: Int,
+) : PopupPositionProvider {
+    override fun calculatePosition(
+        anchorBounds: IntRect,
+        windowSize: IntSize,
+        layoutDirection: LayoutDirection,
+        popupContentSize: IntSize
+    ): IntOffset {
+        val x: Int
+        val y: Int
+
+        val marginHorizontal = (16 * density).toInt()
+        val marginVertical = (12 * density).toInt()
+        val textMargin = (4 * density).toInt()
+
+        val cardHeight = popupContentSize.height - shadowPadding * 2
+        val isSpaceEnoughAtTop = startTopY > cardHeight + textMargin + marginVertical
+
+        if (isSpaceEnoughAtTop) {
+            x = startX - shadowPadding
+            y = startTopY - popupContentSize.height + shadowPadding - textMargin
+        } else if (windowSize.height - startBottomY > cardHeight + textMargin + marginVertical) {
+            x = startX - shadowPadding
+            y = startBottomY + textMargin - shadowPadding
+        } else {
+            x = endX - shadowPadding
+            y = endBottomY + textMargin - shadowPadding
+        }
+
+        val minX = marginHorizontal - shadowPadding
+        val minY = marginVertical - shadowPadding
+        val finalX = x.coerceIn(
+            minX,
+            (windowSize.width - popupContentSize.width - marginHorizontal + shadowPadding)
+                .coerceAtLeast(minX),
+        )
+        val finalY = y.coerceIn(
+            minY,
+            (windowSize.height - popupContentSize.height - marginVertical + shadowPadding)
+                .coerceAtLeast(minY),
+        )
+
+        return IntOffset(finalX, finalY)
+    }
+}

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

@@ -120,10 +120,10 @@ class PageView(
         ReadBookConfig.let {
         ReadBookConfig.let {
             val textColor = it.textColor
             val textColor = it.textColor
             val headerColor = with(ReadBookConfig) {
             val headerColor = with(ReadBookConfig) {
-                if (tipHeaderColor == 0) textColor else tipHeaderColor
+                if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
             }
             }
             val footerColor = with(ReadBookConfig) {
             val footerColor = with(ReadBookConfig) {
-                if (tipFooterColor == 0) textColor else tipFooterColor
+                if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
             }
             }
             val tipDividerColor = with(ReadBookConfig) {
             val tipDividerColor = with(ReadBookConfig) {
                 when (tipDividerColor) {
                 when (tipDividerColor) {
@@ -455,13 +455,6 @@ class PageView(
         binding.contentTextView.invalidate()
         binding.contentTextView.invalidate()
     }
     }
 
 
-    /**
-     * 设置无障碍文本
-     */
-    fun setContentDescription(content: String) {
-        binding.contentTextView.contentDescription = content
-    }
-
     /**
     /**
      * 重置滚动位置
      * 重置滚动位置
      */
      */

+ 61 - 3
app/src/main/java/io/legado/app/ui/book/read/page/ReadView.kt

@@ -1,14 +1,16 @@
 package io.legado.app.ui.book.read.page
 package io.legado.app.ui.book.read.page
 
 
-import android.annotation.SuppressLint
 import android.content.Context
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.Canvas
 import android.graphics.RectF
 import android.graphics.RectF
 import android.os.Build
 import android.os.Build
+import android.os.Bundle
 import android.util.AttributeSet
 import android.util.AttributeSet
 import android.view.MotionEvent
 import android.view.MotionEvent
+import android.view.View
 import android.view.ViewConfiguration
 import android.view.ViewConfiguration
 import android.view.WindowInsets
 import android.view.WindowInsets
+import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
 import android.widget.FrameLayout
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.constant.PageAnim
@@ -123,6 +125,10 @@ class ReadView(
         addView(nextPage)
         addView(nextPage)
         addView(curPage)
         addView(curPage)
         addView(prevPage)
         addView(prevPage)
+        descendantFocusability = FOCUS_BLOCK_DESCENDANTS
+        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        isClickable = true
+        isFocusable = true
         prevPage.invisible()
         prevPage.invisible()
         nextPage.invisible()
         nextPage.invisible()
         curPage.markAsMainView()
         curPage.markAsMainView()
@@ -175,7 +181,6 @@ class ReadView(
     /**
     /**
      * 触摸事件
      * 触摸事件
      */
      */
-    @SuppressLint("ClickableViewAccessibility")
     override fun onTouchEvent(event: MotionEvent): Boolean {
     override fun onTouchEvent(event: MotionEvent): Boolean {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
             val insets = this.rootWindowInsets.getInsetsIgnoringVisibility(
             val insets = this.rootWindowInsets.getInsetsIgnoringVisibility(
@@ -240,6 +245,7 @@ class ReadView(
                 pressDown = false
                 pressDown = false
                 if (pageDelegate?.isMoved == false && !isMove) {
                 if (pageDelegate?.isMoved == false && !isMove) {
                     if (!longPressed && !pressOnTextSelected) {
                     if (!longPressed && !pressOnTextSelected) {
+                        performClick()
                         val handled = curPage.onClick(startX, startY)
                         val handled = curPage.onClick(startX, startY)
                         if (!handled) {
                         if (!handled) {
                             onSingleTapUp()
                             onSingleTapUp()
@@ -271,6 +277,58 @@ class ReadView(
         return true
         return true
     }
     }
 
 
+    override fun performClick(): Boolean {
+        super.performClick()
+        return true
+    }
+
+    override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
+        super.onInitializeAccessibilityNodeInfo(info)
+        info.className = ReadView::class.java.name
+        info.isScrollable = true
+        info.addAction(
+            AccessibilityNodeInfo.AccessibilityAction(
+                AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD,
+                context.getString(R.string.prev_page)
+            )
+        )
+        info.addAction(
+            AccessibilityNodeInfo.AccessibilityAction(
+                AccessibilityNodeInfo.ACTION_SCROLL_FORWARD,
+                context.getString(R.string.next_page)
+            )
+        )
+        info.addAction(
+            AccessibilityNodeInfo.AccessibilityAction(
+                AccessibilityNodeInfo.ACTION_CLICK,
+                context.getString(R.string.menu)
+            )
+        )
+    }
+
+    override fun performAccessibilityAction(action: Int, arguments: Bundle?): Boolean {
+        return when (action) {
+            AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> {
+                pageDelegate?.prevPageByAnim(defaultAnimationSpeed)
+                true
+            }
+
+            AccessibilityNodeInfo.ACTION_SCROLL_FORWARD -> {
+                pageDelegate?.nextPageByAnim(defaultAnimationSpeed)
+                true
+            }
+
+            AccessibilityNodeInfo.ACTION_CLICK -> {
+                performClick()
+                pageDelegate?.dismissSnackBar()
+                callBack.showActionMenu()
+                true
+            }
+
+            else -> super.performAccessibilityAction(action, arguments)
+        }
+    }
+
     fun cancelSelect(clearSearchResult: Boolean = false) {
     fun cancelSelect(clearSearchResult: Boolean = false) {
         if (isTextSelected || clearSearchResult) {
         if (isTextSelected || clearSearchResult) {
             curPage.cancelSelect(clearSearchResult)
             curPage.cancelSelect(clearSearchResult)
@@ -573,7 +631,7 @@ class ReadView(
      */
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
         post {
-            curPage.setContentDescription(pageFactory.curPage.text)
+            contentDescription = pageFactory.curPage.text
         }
         }
         if (isScroll && !isAutoPage) {
         if (isScroll && !isAutoPage) {
             if (relativePosition == 0) {
             if (relativePosition == 0) {

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

@@ -199,8 +199,8 @@ data class TextLine(
         }
         }
         val textColor = if (isReadAloud) {
         val textColor = if (isReadAloud) {
             ReadBookConfig.textAccentColor
             ReadBookConfig.textAccentColor
-        } else if (isTitle && ReadBookConfig.titleColor != 0) {
-            ReadBookConfig.titleColor
+        } else if (isTitle && ReadBookConfig.resolvedTitleColor != 0) {
+            ReadBookConfig.resolvedTitleColor
         } else {
         } else {
             ReadBookConfig.textColor
             ReadBookConfig.textColor
         }
         }

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

@@ -63,8 +63,8 @@ data class TextColumn(
         val drawColor = if (textLine.isReadAloud || isSearchResult) {
         val drawColor = if (textLine.isReadAloud || isSearchResult) {
             ReadBookConfig.textAccentColor
             ReadBookConfig.textAccentColor
         } else {
         } else {
-            textColor ?: if (textLine.isTitle && ReadBookConfig.titleColor != 0) {
-                ReadBookConfig.titleColor
+            textColor ?: if (textLine.isTitle && ReadBookConfig.resolvedTitleColor != 0) {
+                ReadBookConfig.resolvedTitleColor
             } else {
             } else {
                 ReadBookConfig.textColor
                 ReadBookConfig.textColor
             }
             }

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

@@ -1082,7 +1082,7 @@ object ChapterProvider {
         viewWidth = width
         viewWidth = width
         viewHeight = height
         viewHeight = height
         upLayout()
         upLayout()
-        postEvent(EventBus.UP_CONFIG, arrayListOf(5))
+        postEvent(EventBus.UP_CONFIG, arrayListOf(12))
     }
     }
 
 
     /**
     /**

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

@@ -146,6 +146,7 @@ class TextChapterLayout(
     private val textFullJustify = ReadBookConfig.textFullJustify
     private val textFullJustify = ReadBookConfig.textFullJustify
     private val adaptSpecialStyle = ReadConfig.adaptSpecialStyle
     private val adaptSpecialStyle = ReadConfig.adaptSpecialStyle
     private val pageAnim = book.getPageAnim()
     private val pageAnim = book.getPageAnim()
+    private val isSingleImageStyle = book.getImageStyle().equals(Book.imgStyleSingle, true)
     private val titleSegType = ReadBookConfig.titleSegType
     private val titleSegType = ReadBookConfig.titleSegType
     private val titleSegDistance = ReadBookConfig.titleSegDistance
     private val titleSegDistance = ReadBookConfig.titleSegDistance
     private val titleSegFlag = ReadBookConfig.titleSegFlag
     private val titleSegFlag = ReadBookConfig.titleSegFlag
@@ -271,7 +272,6 @@ class TextChapterLayout(
     ) {
     ) {
         val contents = bookContent.textList
         val contents = bookContent.textList
         val imageStyle = book.getImageStyle()
         val imageStyle = book.getImageStyle()
-        val isSingleImageStyle = imageStyle.equals(Book.imgStyleSingle, true)
         val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true)
         val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true)
 
 
         val allTitleSegments = if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) {
         val allTitleSegments = if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) {
@@ -356,16 +356,19 @@ class TextChapterLayout(
         val sb = StringBuffer()
         val sb = StringBuffer()
         var isSetTypedImage = false
         var isSetTypedImage = false
         var wordCount = 0
         var wordCount = 0
-        contents.forEach { content ->
+        contents.forEachIndexed { contentIndex, content ->
             currentCoroutineContext().ensureActive()
             currentCoroutineContext().ensureActive()
+            highlightStyleContext?.bodyContentOffsets?.getOrNull(contentIndex)?.let {
+                bodyHighlightOffset = it
+            }
             if (adaptSpecialStyle) {
             if (adaptSpecialStyle) {
                 val text = content.trim()
                 val text = content.trim()
                 if (text == "[newpage]") {
                 if (text == "[newpage]") {
                     prepareNextPageIfNeed()
                     prepareNextPageIfNeed()
-                    return@forEach
+                    return@forEachIndexed
                 } else if (text.startsWith("<usehtml>")) {
                 } else if (text.startsWith("<usehtml>")) {
                     setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<")))
                     setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<")))
-                    return@forEach
+                    return@forEachIndexed
                 }
                 }
             }
             }
             var text = content.replace(srcReplaceCharC, srcReplaceCharD)
             var text = content.replace(srcReplaceCharC, srcReplaceCharD)
@@ -1034,8 +1037,9 @@ class TextChapterLayout(
             val textPage = pendingTextPage
             val textPage = pendingTextPage
             textPage.addLine(textLine)
             textPage.addLine(textLine)
             durY += textHeight * if (isTitle) titleLineSpacingExtra else lineSpacingExtra
             durY += textHeight * if (isTitle) titleLineSpacingExtra else lineSpacingExtra
-            if (textPage.height < durY) {
-                textPage.height = durY
+            val pageHeight = if (isSingleImageStyle) visibleHeight.toFloat() else durY
+            if (textPage.height < pageHeight) {
+                textPage.height = pageHeight
             }
             }
         }
         }
         durY += textHeight * paragraphSpacing / 10f
         durY += textHeight * paragraphSpacing / 10f
@@ -1421,13 +1425,14 @@ class TextChapterLayout(
             isTitle = true,
             isTitle = true,
             rules = rules
             rules = rules
         )
         )
+        val bodyHighlightText = buildBodyHighlightText(contents)
         val bodyStyles = createHighlightStyles(
         val bodyStyles = createHighlightStyles(
-            text = buildBodyHighlightText(contents),
+            text = bodyHighlightText.text,
             isTitle = false,
             isTitle = false,
             rules = rules
             rules = rules
         )
         )
         if (titleStyles == null && bodyStyles == null) return null
         if (titleStyles == null && bodyStyles == null) return null
-        return HighlightStyleContext(titleStyles, bodyStyles)
+        return HighlightStyleContext(titleStyles, bodyStyles, bodyHighlightText.contentOffsets)
     }
     }
 
 
     private fun buildTitleHighlightText(titleSegments: List<TitleSegment>?): String {
     private fun buildTitleHighlightText(titleSegments: List<TitleSegment>?): String {
@@ -1440,14 +1445,16 @@ class TextChapterLayout(
         }
         }
     }
     }
 
 
-    private fun buildBodyHighlightText(contents: List<String>): String {
-        if (contents.isEmpty()) return ""
-        return buildString {
-            contents.forEach { content ->
+    private fun buildBodyHighlightText(contents: List<String>): BodyHighlightText {
+        if (contents.isEmpty()) return BodyHighlightText("", IntArray(0))
+        val contentOffsets = IntArray(contents.size)
+        val text = buildString {
+            contents.forEachIndexed { index, content ->
+                contentOffsets[index] = length
                 if (adaptSpecialStyle) {
                 if (adaptSpecialStyle) {
                     val text = content.trim()
                     val text = content.trim()
                     if (text == "[newpage]" || text.startsWith("<usehtml>")) {
                     if (text == "[newpage]" || text.startsWith("<usehtml>")) {
-                        return@forEach
+                        return@forEachIndexed
                     }
                     }
                 }
                 }
                 append(
                 append(
@@ -1457,6 +1464,7 @@ class TextChapterLayout(
                 append('\n')
                 append('\n')
             }
             }
         }
         }
+        return BodyHighlightText(text, contentOffsets)
     }
     }
 
 
     private fun String.replaceImagesForHighlight(): String {
     private fun String.replaceImagesForHighlight(): String {
@@ -1521,6 +1529,7 @@ class TextChapterLayout(
     private data class HighlightStyleContext(
     private data class HighlightStyleContext(
         val titleStyles: Array<CharStyle?>?,
         val titleStyles: Array<CharStyle?>?,
         val bodyStyles: Array<CharStyle?>?,
         val bodyStyles: Array<CharStyle?>?,
+        val bodyContentOffsets: IntArray,
     ) {
     ) {
         fun stylesFor(isTitle: Boolean, offset: Int, length: Int): Array<CharStyle?>? {
         fun stylesFor(isTitle: Boolean, offset: Int, length: Int): Array<CharStyle?>? {
             if (length <= 0) return null
             if (length <= 0) return null
@@ -1546,6 +1555,11 @@ class TextChapterLayout(
         }
         }
     }
     }
 
 
+    private data class BodyHighlightText(
+        val text: String,
+        val contentOffsets: IntArray,
+    )
+
     private data class CompiledHighlightRule(
     private data class CompiledHighlightRule(
         val rule: HighlightRule,
         val rule: HighlightRule,
         val regex: Regex,
         val regex: Regex,

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt

@@ -74,6 +74,7 @@ fun AiTextRewriteSheet(
                     Icons.Default.AutoAwesome
                     Icons.Default.AutoAwesome
                 else
                 else
                     Icons.Default.Autorenew,
                     Icons.Default.Autorenew,
+                contentDescription = stringResource(R.string.ai_generate),
                 enabled = state.presets.isNotEmpty() &&
                 enabled = state.presets.isNotEmpty() &&
                         !state.isLoading &&
                         !state.isLoading &&
                         !state.isApplying
                         !state.isApplying
@@ -83,6 +84,7 @@ fun AiTextRewriteSheet(
             MediumTonalButton(
             MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ConfirmAiTextRewrite) },
                 onClick = { onIntent(ReadBookIntent.ConfirmAiTextRewrite) },
                 icon = Icons.Default.Save,
                 icon = Icons.Default.Save,
+                contentDescription = stringResource(R.string.save),
                 enabled = !state.isLoading &&
                 enabled = !state.isLoading &&
                         !state.isApplying &&
                         !state.isApplying &&
                         state.errorMessage == null &&
                         state.errorMessage == null &&

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

@@ -70,7 +70,8 @@ fun ChangeChapterSourceSheet(
                 Box {
                 Box {
                     MediumPlainButton(
                     MediumPlainButton(
                         onClick = { showOptionsMenu = true },
                         onClick = { showOptionsMenu = true },
-                        icon = Icons.Default.MoreVert
+                icon = Icons.Default.MoreVert,
+                contentDescription = stringResource(R.string.more_menu)
                     )
                     )
                     RoundDropdownMenu(
                     RoundDropdownMenu(
                         expanded = showOptionsMenu,
                         expanded = showOptionsMenu,
@@ -117,11 +118,15 @@ fun ChangeChapterSourceSheet(
                 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                     MediumPlainButton(
                     MediumPlainButton(
                         onClick = { onIntent(ChangeChapterSourceIntent.StartStopSearch) },
                         onClick = { onIntent(ChangeChapterSourceIntent.StartStopSearch) },
-                        icon = if (state.isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
+                icon = if (state.isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh,
+                contentDescription = stringResource(
+                    if (state.isSearching) R.string.pause else R.string.refresh
+                ),
                     )
                     )
                     MediumPlainButton(
                     MediumPlainButton(
                         onClick = { showFilterSheet = true },
                         onClick = { showFilterSheet = true },
-                        icon = Icons.Default.FilterList
+                icon = Icons.Default.FilterList,
+                contentDescription = stringResource(R.string.screen)
                     )
                     )
                 }
                 }
             }
             }
@@ -261,7 +266,7 @@ private fun SearchContent(
                             onClick = { onBookScoreClick(item) },
                             onClick = { onBookScoreClick(item) },
                             icon = Icons.Default.PushPin,
                             icon = Icons.Default.PushPin,
                             tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
                             tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
-                            contentDescription = null
+                            contentDescription = stringResource(R.string.a11y_pin_source)
                         )
                         )
                     },
                     },
                     supportingContent = {
                     supportingContent = {

+ 7 - 10
app/src/main/java/io/legado/app/ui/book/read/sheet/ClickActionConfigSheet.kt

@@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
@@ -67,7 +68,7 @@ fun ClickActionConfigSheet(
     Box(
     Box(
         modifier = Modifier
         modifier = Modifier
             .fillMaxSize()
             .fillMaxSize()
-            .background(LegadoTheme.colorScheme.scrim.copy(alpha = 0.6f))
+            .background(LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.4f))
             .clickable(onClick = onDismissRequest),
             .clickable(onClick = onDismissRequest),
     ) {
     ) {
         Column(
         Column(
@@ -75,13 +76,9 @@ fun ClickActionConfigSheet(
                 .fillMaxSize()
                 .fillMaxSize()
                 .padding(12.dp),
                 .padding(12.dp),
         ) {
         ) {
-            AppText(
-                text = stringResource(R.string.click_regional_config),
-                style = LegadoTheme.typography.titleMedium,
-                modifier = Modifier.padding(bottom = 12.dp),
+            Spacer(
+                modifier = Modifier.padding(top = 36.dp)
             )
             )
-
-            // 3x3 grid
             Row(
             Row(
                 modifier = Modifier
                 modifier = Modifier
                     .weight(1f)
                     .weight(1f)
@@ -194,9 +191,9 @@ fun ClickActionConfigSheet(
                                 selectingPrefKey?.let { key ->
                                 selectingPrefKey?.let { key ->
                                     scope.launch {
                                     scope.launch {
                                         readSettingsRepository.setClickAction(key, selectedAction)
                                         readSettingsRepository.setClickAction(key, selectedAction)
+                                        selectingPrefKey = null
                                     }
                                     }
                                 }
                                 }
-                                selectingPrefKey = null
                             }
                             }
                             .padding(horizontal = 24.dp, vertical = 12.dp),
                             .padding(horizontal = 24.dp, vertical = 12.dp),
                         style = LegadoTheme.typography.bodyLarge,
                         style = LegadoTheme.typography.bodyLarge,
@@ -216,8 +213,8 @@ private fun ClickAreaCell(
     GlassCard(
     GlassCard(
         modifier = modifier,
         modifier = modifier,
         onClick = onClick,
         onClick = onClick,
-        containerColor = LegadoTheme.colorScheme.surfaceVariant
-            .copy(alpha = 0.5f),
+        containerColor = LegadoTheme.colorScheme.surfaceContainer
+            .copy(alpha = 0.9f),
     ) {
     ) {
         Box(
         Box(
             modifier = Modifier.fillMaxSize(),
             modifier = Modifier.fillMaxSize(),

+ 26 - 32
app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt

@@ -17,9 +17,11 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.runtime.snapshotFlow
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Restore
+import androidx.compose.material.icons.filled.Save
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.text.TextLayoutResult
 import androidx.compose.ui.text.TextLayoutResult
@@ -29,12 +31,11 @@ import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
-import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.checkBox.AppCheckbox
 import io.legado.app.ui.widget.components.checkBox.AppCheckbox
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.utils.sendToClip
 
 
 @Composable
 @Composable
 fun ContentEditSheet(
 fun ContentEditSheet(
@@ -43,7 +44,6 @@ fun ContentEditSheet(
     onIntent: (ReadBookIntent) -> Unit,
     onIntent: (ReadBookIntent) -> Unit,
     onDismissRequest: () -> Unit,
     onDismissRequest: () -> Unit,
 ) {
 ) {
-    val context = LocalContext.current
     val editorState = remember { TextFieldState() }
     val editorState = remember { TextFieldState() }
     val editorScrollState = rememberScrollState()
     val editorScrollState = rememberScrollState()
     var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
     var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -98,40 +98,34 @@ fun ContentEditSheet(
         show = show,
         show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
         title = state.contentEditTitle,
         title = state.contentEditTitle,
+        startAction = {
+            MediumTonalButton(
+                onClick = { onIntent(ReadBookIntent.ResetContentEdit) },
+                icon = Icons.Default.Restore,
+                contentDescription = stringResource(R.string.reset),
+            )
+        },
+        endAction = {
+            MediumTonalButton(
+                onClick = {
+                    onIntent(
+                        ReadBookIntent.SaveContentEdit(
+                            editorState.text.toString(),
+                            state.contentEditSaveToSource,
+                        )
+                    )
+                    onDismissRequest()
+                },
+                icon = Icons.Default.Save,
+                contentDescription = stringResource(R.string.action_save),
+            )
+        },
     ) {
     ) {
         Column(
         Column(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxWidth()
                 .fillMaxWidth()
                 .padding(bottom = 16.dp),
                 .padding(bottom = 16.dp),
         ) {
         ) {
-            Row(modifier = Modifier.fillMaxWidth()) {
-                SmallTonalButton(
-                    onClick = {
-                        onIntent(
-                            ReadBookIntent.SaveContentEdit(
-                                editorState.text.toString(),
-                                state.contentEditSaveToSource
-                            )
-                        )
-                        onDismissRequest()
-                    },
-                    text = stringResource(R.string.action_save)
-                )
-                SmallTonalButton(
-                    onClick = {
-                    onIntent(ReadBookIntent.ResetContentEdit)
-                    },
-                    text = stringResource(R.string.reset)
-                )
-                SmallTonalButton(
-                    onClick = {
-                    context.sendToClip("${state.contentEditTitle}\n${editorState.text}")
-                    }, text = stringResource(R.string.copy_all)
-                )
-            }
-
-            Spacer(Modifier.height(8.dp))
-
             if (state.contentEditLoading) {
             if (state.contentEditLoading) {
                 Box(
                 Box(
                     modifier = Modifier
                     modifier = Modifier

+ 25 - 13
app/src/main/java/io/legado/app/ui/book/read/sheet/EffectiveReplacesSheet.kt

@@ -38,6 +38,7 @@ import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 
 
 private const val CHINESE_CONVERT_ID = -1L
 private const val CHINESE_CONVERT_ID = -1L
+private const val RE_SEGMENT_ID = -2L
 
 
 @Composable
 @Composable
 fun EffectiveReplacesSheet(
 fun EffectiveReplacesSheet(
@@ -51,16 +52,18 @@ fun EffectiveReplacesSheet(
     val scope = rememberCoroutineScope()
     val scope = rememberCoroutineScope()
     val chineseConvertActive = ReadConfig.chineseConverterType > 0
     val chineseConvertActive = ReadConfig.chineseConverterType > 0
     val chineseConvertItem = remember { ReplaceRule(CHINESE_CONVERT_ID, "繁简转换") }
     val chineseConvertItem = remember { ReplaceRule(CHINESE_CONVERT_ID, "繁简转换") }
+    val reSegmentActive = ReadBook.book?.getReSegment() == true
+    val reSegmentItem = remember { ReplaceRule(RE_SEGMENT_ID, "") }
 
 
     val effectiveRules = remember(show) {
     val effectiveRules = remember(show) {
         ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList()
         ReadBook.curTextChapter?.effectiveReplaceRules ?: emptyList()
     }
     }
 
 
-    val items = remember(show, effectiveRules, chineseConvertActive) {
-        if (chineseConvertActive) {
-            effectiveRules + chineseConvertItem
-        } else {
-            effectiveRules
+    val items = remember(show, effectiveRules, chineseConvertActive, reSegmentActive) {
+        buildList {
+            addAll(effectiveRules)
+            if (chineseConvertActive) add(chineseConvertItem)
+            if (reSegmentActive) add(reSegmentItem)
         }
         }
     }
     }
 
 
@@ -92,7 +95,7 @@ fun EffectiveReplacesSheet(
                         onClick = {
                         onClick = {
                             if (rule.id == CHINESE_CONVERT_ID) {
                             if (rule.id == CHINESE_CONVERT_ID) {
                                 onNavigateToTextEffects()
                                 onNavigateToTextEffects()
-                            } else {
+                            } else if (rule.id != RE_SEGMENT_ID) {
                                 onOpenReplaceEditor(rule.id, rule.pattern)
                                 onOpenReplaceEditor(rule.id, rule.pattern)
                             }
                             }
                         },
                         },
@@ -109,12 +112,16 @@ fun EffectiveReplacesSheet(
                         ) {
                         ) {
                             Column(modifier = Modifier.weight(1f)) {
                             Column(modifier = Modifier.weight(1f)) {
                                 AppText(
                                 AppText(
-                                    text = rule.name,
+                                    text = if (rule.id == RE_SEGMENT_ID) {
+                                        stringResource(R.string.re_segment)
+                                    } else {
+                                        rule.name
+                                    },
                                     style = LegadoTheme.typography.labelLargeEmphasized,
                                     style = LegadoTheme.typography.labelLargeEmphasized,
                                     maxLines = 1,
                                     maxLines = 1,
                                     overflow = TextOverflow.Ellipsis,
                                     overflow = TextOverflow.Ellipsis,
                                 )
                                 )
-                                if (rule.id != CHINESE_CONVERT_ID) {
+                                if (rule.id >= 0) {
                                     Row(verticalAlignment = Alignment.CenterVertically) {
                                     Row(verticalAlignment = Alignment.CenterVertically) {
                                         AppText(
                                         AppText(
                                             text = rule.pattern,
                                             text = rule.pattern,
@@ -146,16 +153,21 @@ fun EffectiveReplacesSheet(
                                 onClick = {
                                 onClick = {
                                     disabledIds = disabledIds + rule.id
                                     disabledIds = disabledIds + rule.id
                                     isEdited = true
                                     isEdited = true
-                                    if (rule.id == CHINESE_CONVERT_ID) {
-                                        ReadConfig.chineseConverterType = 0
-                                    } else {
-                                        scope.launch {
+                                    when (rule.id) {
+                                        RE_SEGMENT_ID -> {
+                                            ReadBook.book?.setReSegment(false)
+                                            ReadBook.loadContent(false)
+                                        }
+
+                                        CHINESE_CONVERT_ID -> ReadConfig.chineseConverterType = 0
+                                        else -> scope.launch {
                                             rule.isEnabled = false
                                             rule.isEnabled = false
                                             appDb.replaceRuleDao.insert(rule)
                                             appDb.replaceRuleDao.insert(rule)
                                         }
                                         }
                                     }
                                     }
                                 },
                                 },
-                                icon = Icons.Default.Close
+                icon = Icons.Default.Close,
+                contentDescription = stringResource(R.string.close)
                             )
                             )
                         }
                         }
                     }
                     }

+ 55 - 14
app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt

@@ -54,8 +54,11 @@ import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.SectionTitle
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
+import io.legado.app.ui.widget.components.pager.pagerHeight
+import io.legado.app.ui.widget.components.pager.rememberPagerAnimatedHeight
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinyColorModeSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyColorSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyDropdownSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
@@ -69,6 +72,8 @@ import org.koin.compose.koinInject
 private const val COLOR_HEADER = 7
 private const val COLOR_HEADER = 7
 private const val COLOR_FOOTER = 8
 private const val COLOR_FOOTER = 8
 private const val COLOR_DIVIDER = 9
 private const val COLOR_DIVIDER = 9
+private const val COLOR_HEADER_NIGHT = 10
+private const val COLOR_FOOTER_NIGHT = 11
 
 
 @Composable
 @Composable
 internal fun HeaderFooterPage(
 internal fun HeaderFooterPage(
@@ -293,19 +298,33 @@ internal fun HeaderFooterPage(
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(it)))
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(it)))
                             },
                             },
                         )
                         )
-                        TinyColorSettingItem(
+                        TinyColorModeSettingItem(
                             title = stringResource(R.string.header_color),
                             title = stringResource(R.string.header_color),
-                            colorValue = if (ReadBookConfig.tipHeaderColor != 0) {
+                            dayColor = if (ReadBookConfig.tipHeaderColor != 0) {
                                 ReadBookConfig.tipHeaderColor
                                 ReadBookConfig.tipHeaderColor
                             } else {
                             } else {
                                 ReadBookConfig.textColor
                                 ReadBookConfig.textColor
                             },
                             },
-                            onClick = {
-                                colorPickerId = COLOR_HEADER
-                                colorPickerInitial = if (ReadBookConfig.tipHeaderColor != 0) {
-                                    ReadBookConfig.tipHeaderColor
+                            nightColor = if (ReadBookConfig.tipHeaderColorNight != 0) {
+                                ReadBookConfig.tipHeaderColorNight
+                            } else {
+                                ReadBookConfig.textColorNight
+                            },
+                            onClickColor = { isNight ->
+                                if (isNight) {
+                                    colorPickerId = COLOR_HEADER_NIGHT
+                                    colorPickerInitial = if (ReadBookConfig.tipHeaderColorNight != 0) {
+                                        ReadBookConfig.tipHeaderColorNight
+                                    } else {
+                                        ReadBookConfig.textColorNight
+                                    }
                                 } else {
                                 } else {
-                                    ReadBookConfig.textColor
+                                    colorPickerId = COLOR_HEADER
+                                    colorPickerInitial = if (ReadBookConfig.tipHeaderColor != 0) {
+                                        ReadBookConfig.tipHeaderColor
+                                    } else {
+                                        ReadBookConfig.textColor
+                                    }
                                 }
                                 }
                                 showColorPicker = true
                                 showColorPicker = true
                             },
                             },
@@ -423,19 +442,33 @@ internal fun HeaderFooterPage(
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(it)))
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(it)))
                             },
                             },
                         )
                         )
-                        TinyColorSettingItem(
+                        TinyColorModeSettingItem(
                             title = stringResource(R.string.footer_color),
                             title = stringResource(R.string.footer_color),
-                            colorValue = if (ReadBookConfig.tipFooterColor != 0) {
+                            dayColor = if (ReadBookConfig.tipFooterColor != 0) {
                                 ReadBookConfig.tipFooterColor
                                 ReadBookConfig.tipFooterColor
                             } else {
                             } else {
                                 ReadBookConfig.textColor
                                 ReadBookConfig.textColor
                             },
                             },
-                            onClick = {
-                                colorPickerId = COLOR_FOOTER
-                                colorPickerInitial = if (ReadBookConfig.tipFooterColor != 0) {
-                                    ReadBookConfig.tipFooterColor
+                            nightColor = if (ReadBookConfig.tipFooterColorNight != 0) {
+                                ReadBookConfig.tipFooterColorNight
+                            } else {
+                                ReadBookConfig.textColorNight
+                            },
+                            onClickColor = { isNight ->
+                                if (isNight) {
+                                    colorPickerId = COLOR_FOOTER_NIGHT
+                                    colorPickerInitial = if (ReadBookConfig.tipFooterColorNight != 0) {
+                                        ReadBookConfig.tipFooterColorNight
+                                    } else {
+                                        ReadBookConfig.textColorNight
+                                    }
                                 } else {
                                 } else {
-                                    ReadBookConfig.textColor
+                                    colorPickerId = COLOR_FOOTER
+                                    colorPickerInitial = if (ReadBookConfig.tipFooterColor != 0) {
+                                        ReadBookConfig.tipFooterColor
+                                    } else {
+                                        ReadBookConfig.textColor
+                                    }
                                 }
                                 }
                                 showColorPicker = true
                                 showColorPicker = true
                             },
                             },
@@ -555,10 +588,18 @@ internal fun HeaderFooterPage(
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color)))
                 }
                 }
 
 
+                COLOR_HEADER_NIGHT -> {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColorNight(color)))
+                }
+
                 COLOR_FOOTER -> {
                 COLOR_FOOTER -> {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color)))
                 }
                 }
 
 
+                COLOR_FOOTER_NIGHT -> {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColorNight(color)))
+                }
+
                 COLOR_DIVIDER -> {
                 COLOR_DIVIDER -> {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipDividerColor(color)))
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipDividerColor(color)))
                 }
                 }

+ 18 - 6
app/src/main/java/io/legado/app/ui/book/read/sheet/HighlightRuleConfigSheet.kt

@@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
@@ -42,6 +42,7 @@ import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 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.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.reorderAccessibility
 import io.legado.app.ui.widget.components.settingItem.TinySettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySettingItem
 import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.rememberReorderableLazyListState
 import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -121,7 +122,8 @@ fun HighlightRuleConfigSheet(
         startAction = {
         startAction = {
             SmallTonalButton(
             SmallTonalButton(
                 onClick = { onIntent(ReadBookIntent.AddHighlightRule) },
                 onClick = { onIntent(ReadBookIntent.AddHighlightRule) },
-                icon = Icons.Default.Add
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.add)
             )
             )
         },
         },
         endAction = {
         endAction = {
@@ -129,7 +131,8 @@ fun HighlightRuleConfigSheet(
             Box {
             Box {
                 SmallTonalButton(
                 SmallTonalButton(
                     onClick = { expanded = true },
                     onClick = { expanded = true },
-                    icon = Icons.Default.MoreVert
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu)
                 )
                 )
                 RoundDropdownMenu(
                 RoundDropdownMenu(
                     expanded = expanded,
                     expanded = expanded,
@@ -170,7 +173,7 @@ fun HighlightRuleConfigSheet(
                 verticalArrangement = Arrangement.spacedBy(8.dp),
                 verticalArrangement = Arrangement.spacedBy(8.dp),
                 modifier = Modifier.weight(1f, fill = false),
                 modifier = Modifier.weight(1f, fill = false),
             ) {
             ) {
-                items(state.rules, key = { it.id }) { rule ->
+                itemsIndexed(state.rules, key = { _, rule -> rule.id }) { index, rule ->
                     ReorderableItem(reorderableState, key = rule.id) { isDragging ->
                     ReorderableItem(reorderableState, key = rule.id) { isDragging ->
                         HighlightRuleItem(
                         HighlightRuleItem(
                             rule = rule,
                             rule = rule,
@@ -184,6 +187,13 @@ fun HighlightRuleConfigSheet(
                                 onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
                                 onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
                             },
                             },
                             modifier = Modifier
                             modifier = Modifier
+                                .reorderAccessibility(
+                                    index = index,
+                                    itemCount = state.rules.size,
+                                ) { from, to ->
+                                    onIntent(ReadBookIntent.MoveHighlightRule(from, to))
+                                    onIntent(ReadBookIntent.SaveHighlightRuleOrder)
+                                }
                                 .longPressDraggableHandle()
                                 .longPressDraggableHandle()
                                 .zIndex(if (isDragging) 1f else 0f)
                                 .zIndex(if (isDragging) 1f else 0f)
                                 .animateItem(),
                                 .animateItem(),
@@ -251,11 +261,13 @@ private fun HighlightRuleItem(
                 )
                 )
                 SmallTonalButton(
                 SmallTonalButton(
                     onClick = onEditClick,
                     onClick = onEditClick,
-                    icon = Icons.Default.Edit
+                    icon = Icons.Default.Edit,
+                    contentDescription = stringResource(R.string.edit)
                 )
                 )
                 SmallTonalButton(
                 SmallTonalButton(
                     onClick = onDeleteClick,
                     onClick = onDeleteClick,
-                    icon = Icons.Default.Delete
+                    icon = Icons.Default.Delete,
+                    contentDescription = stringResource(R.string.delete)
                 )
                 )
             }
             }
         },
         },

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

@@ -231,7 +231,7 @@ fun HighlightRuleEditSheet(
             }) {
             }) {
                 androidx.compose.material3.Icon(
                 androidx.compose.material3.Icon(
                     Icons.Default.Done,
                     Icons.Default.Done,
-                    contentDescription = null,
+                    contentDescription = stringResource(R.string.save),
                 )
                 )
             }
             }
         },
         },

Некоторые файлы не были показаны из-за большого количества измененных файлов