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

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:
   push:
@@ -19,6 +19,13 @@ on:
     types:
       - completed
   workflow_dispatch:
+  workflow_call:
+    inputs:
+      publish:
+        description: '构建完成后创建 GitHub Release 并发布到 Telegram'
+        required: false
+        type: boolean
+        default: false
 
 permissions:
   contents: write
@@ -82,7 +89,7 @@ jobs:
                 LAST_TAG_FOR_LOG=$(git tag --sort=-committerdate | head -n 2 | tail -n 1)
             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 分支推送
           
             # 查找匹配当前 CURRENT_VERSION 的上一个 BETA TAG (e.g., 3.26.4-beta.X)
@@ -242,15 +249,27 @@ jobs:
           if-no-files-found: ignore
           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:
     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
     steps:
       - uses: actions/checkout@v5
@@ -262,10 +281,9 @@ jobs:
       - name: 下载所有 APK Artifacts
         uses: actions/download-artifact@v4
         with:
-          pattern: '*-apk-*'
+          pattern: '*-apk-release'
           path: artifacts
           merge-multiple: true
-          if-no-artifact-found: error
 
       # 2. 下载 mapping 文件
       - name: 下载 Mapping Artifacts
@@ -311,7 +329,11 @@ jobs:
           LAST_TAG="${{ needs.prepare.outputs.latest_tag }}"
           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
               CONTENT="- 没有新的 Commit 记录."
@@ -361,6 +383,19 @@ jobs:
         env:
           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 频道
         env:
           TG_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}

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

@@ -7,10 +7,6 @@ on:
         description: '要构建的分支'
         required: true
         default: 'trae/solo-agent-vhkVM3'
-      release_tag:
-        description: 'Release 标签 (留空则不上传到 Release)'
-        required: false
-        default: ''
 
 permissions:
   contents: write
@@ -90,20 +86,3 @@ jobs:
         with:
           name: legado-apks-zip-${{ github.run_number }}
           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"))
     coreLibraryDesugaring(libs.desugar)
     testImplementation(libs.junit)
+    testImplementation(libs.robolectric)
     androidTestImplementation(libs.bundles.androidTest)
     implementation(libs.kotlin.stdlib)
     implementation(libs.kotlinx.collections.immutable)

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

@@ -39,6 +39,7 @@
         <activity
             android:name=".ui.main.LauncherW"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcherw">
             <intent-filter>
@@ -54,6 +55,7 @@
         <activity
             android:name=".ui.main.Launcher0"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher0">
             <intent-filter>
@@ -70,6 +72,7 @@
         <activity
             android:name=".ui.main.Launcher1"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher1">
             <intent-filter>
@@ -86,6 +89,7 @@
         <activity
             android:name=".ui.main.Launcher2"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher2">
             <intent-filter>
@@ -102,6 +106,7 @@
         <activity
             android:name=".ui.main.Launcher3"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher3">
             <intent-filter>
@@ -118,6 +123,7 @@
         <activity
             android:name=".ui.main.Launcher4"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher4">
             <intent-filter>
@@ -134,6 +140,7 @@
         <activity
             android:name=".ui.main.Launcher5"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher5">
             <intent-filter>
@@ -150,6 +157,7 @@
         <activity
             android:name=".ui.main.Launcher6"
             android:enabled="false"
+            android:configChanges="uiMode"
             android:exported="true"
             android:icon="@mipmap/launcher6">
             <intent-filter>
@@ -166,6 +174,7 @@
         <activity
             android:name=".ui.main.MainActivity"
             android:alwaysRetainTaskState="true"
+            android:configChanges="uiMode"
             android:enableOnBackInvokedCallback="true"
             android:windowSoftInputMode="adjustResize"
             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() {
         when (getPrefString("app_theme", "0")) {
             "0" -> {
-                DynamicColors.applyToActivitiesIfAvailable(application)
+                DynamicColors.applyToActivityIfAvailable(this)
             }
             "1" -> setTheme(R.style.Theme_Base_GR)
             "2" -> setTheme(R.style.Theme_Base_Lemon)
@@ -182,10 +182,8 @@ abstract class BaseActivity<VB : ViewBinding>(
             "10" -> setTheme(R.style.Theme_Base_Phoebe)
             "11" -> setTheme(R.style.Theme_Base_Mujika)
             "12" -> {
-                if (AppConfig.customMode == "accent")
-                    setTheme(R.style.ThemeOverlay_WhiteBackground)
-
                 val colorImagePath = getPrefString(PreferKey.colorImage)
+                var colorImageApplied = false
                 if (!colorImagePath.isNullOrBlank()) {
                     val file = File(colorImagePath)
                     if (file.exists()) {
@@ -200,18 +198,26 @@ abstract class BaseActivity<VB : ViewBinding>(
                                 .setContentBasedSource(scaledBitmap)
                                 .build()
 
-                            DynamicColors.applyToActivitiesIfAvailable(application, options)
+                            DynamicColors.applyToActivityIfAvailable(this, options)
                             bitmap.recycle()
+                            colorImageApplied = true
                         }
                     }
-                }else{
-                    DynamicColors.applyToActivitiesIfAvailable(
-                        application,
+                }
+                if (!colorImageApplied) {
+                    // 取色图片缺失或解码失败时回退到种子色,
+                    // 否则该 Activity 完全拿不到动态配色,与 Compose 界面不一致
+                    DynamicColors.applyToActivityIfAvailable(
+                        this,
                         DynamicColorsOptions.Builder()
                             .setContentBasedSource(application.primaryColor)
                             .build()
                     )
                 }
+
+                // 必须在动态取色之后应用,否则会被动态配色的 surface/background 覆盖
+                if (AppConfig.customMode == "accent")
+                    setTheme(R.style.ThemeOverlay_WhiteBackground)
             }
 
             "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
 
+import android.content.res.Configuration
 import android.os.Bundle
 import android.os.Build
 import androidx.activity.compose.setContent
 import androidx.activity.enableEdgeToEdge
 import androidx.appcompat.app.AppCompatActivity
 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.view.WindowCompat
 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.ThemeConfigStore
 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.fullScreen
 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.toggleSystemBar
 import io.legado.app.utils.windowSize
+import top.yukonga.miuix.kmp.theme.MiuixTheme
 
 abstract class BaseComposeActivity(
     val fullScreen: Boolean = true,
@@ -45,6 +51,7 @@ abstract class BaseComposeActivity(
         // Compose 入口
         setContent {
             AppTheme {
+                SyncWindowBackground()
                 Content()
             }
         }
@@ -56,6 +63,18 @@ abstract class BaseComposeActivity(
         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() {
         WindowCompat.setDecorFitsSystemWindows(window, false)
 
@@ -73,11 +92,39 @@ abstract class BaseComposeActivity(
     open fun upBackgroundImage() {
         try {
             ThemeConfigStore.getBgImage(this, windowManager.windowSize)?.let {
+                hasWindowBgImage = true
                 window.setBackgroundDrawable(it.toDrawable(resources))
             }
         } 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() {
         observeEvent<String>(EventBus.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 {
     val JS_PATTERN: Pattern =
         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]*?)\\}\\}")
 
     //匹配格式化后的图片格式

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

@@ -13,6 +13,7 @@ object IntentAction {
     const val prevParagraph = "prevParagraph"
     const val nextParagraph = "nextParagraph"
     const val upTtsSpeechRate = "upTtsSpeechRate"
+    const val syncReadAloudLayout = "syncReadAloudLayout"
     const val upTtsProgress = "upTtsProgress"
     const val adjustProgress = "adjustProgress"
     const val adjustSpeed = "adjustSpeed"
@@ -22,4 +23,4 @@ object IntentAction {
     const val init = "init"
     const val remove = "remove"
     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 expandTextMenu = "expandTextMenu"
     const val showSelectMenuIcon = "showSelectMenuIcon"
-    const val textSelectMenuFilter = "textSelectMenuFilter"
+    const val textSelectMenuConfig = "textSelectMenuConfig"
     const val doublePageHorizontal = "doubleHorizontalPage"
     const val readUrlOpenInBrowser = "readUrlInBrowser"
     const val defaultBookTreeUri = "defaultBookTreeUri"
@@ -180,6 +180,7 @@ object PreferKey {
     const val recordHeapDump = "recordHeapDump"
     const val optimizeRender = "optimizeRender"
     const val updateToVariant = "updateToVariant"
+    const val autoCheckUpdateOnStart = "autoCheckUpdateOnStart"
     const val streamReadAloudAudio = "streamReadAloudAudio"
     const val pauseReadAloudWhilePhoneCalls = "pauseReadAloudWhilePhoneCalls"
     const val readAloudByMediaButton = "readAloudByMediaButton"
@@ -207,6 +208,8 @@ object PreferKey {
     const val useFlexibleTopAppBar = "useFlexibleTopAppBar"
     const val bookInfoFollowCoverColor = "bookInfoFollowCoverColor"
     const val bookInfoBackgroundBlur = "bookInfoBackgroundBlur"
+    const val bookInfoNetworkCoverBackground = "bookInfoNetworkCoverBackground"
+    const val bookInfoDefaultCoverBackground = "bookInfoDefaultCoverBackground"
     const val cBackground = "colorBackground"
     const val cBBackground = "colorBottomBackground"
     const val enableDeepPersonalization = "enableDeepPersonalization"
@@ -297,6 +300,7 @@ object PreferKey {
     const val bookshelfRefreshingLimit = "bookshelfRefreshingLimit"
     const val systemMediaControlCompatibilityChange = "systemMediaControlCompatibilityChange"
     const val isPredictiveBackEnabled = "isPredictiveBackEnabled"
+    const val readRecordDisplayMode = "readRecordDisplayMode"
     const val replaceSortMode = "desc"
     const val readBarStyle = "readBarStyle"
     const val readMenuBgColor = "readMenuBgColor"
@@ -305,9 +309,12 @@ object PreferKey {
     const val readMenuBgColorNight = "readMenuBgColorNight"
     const val readMenuAccentColorNight = "readMenuAccentColorNight"
     const val readMenuContainerColorNight = "readMenuContainerColorNight"
+    const val readMenuTextColor = "readMenuTextColor"
+    const val readMenuTextColorNight = "readMenuTextColorNight"
     const val readMenuColorMode = "readMenuColorMode"
     const val readMenuIconShowText = "readMenuIconShowText"
     const val readMenuIconStyle = "readMenuIconStyle"
+    const val titleBarIconStyle = "titleBarIconStyle"
     const val readMenuIconItemsPerRow = "readMenuIconItemsPerRow"
     const val readMenuIconRowCount = "readMenuIconRowCount"
     const val readMenuBottomCornerRadius = "readMenuBottomCornerRadius"
@@ -322,6 +329,7 @@ object PreferKey {
     const val readMenuBlurRadius = "readMenuBlurRadius"
     const val readMenuBlurAlpha = "readMenuBlurAlpha"
     const val readMenuBlurColor = "readMenuBlurColor"
+    const val readMenuBlurColorNight = "readMenuBlurColorNight"
     const val readMenuPaletteStyle = "readMenuPaletteStyle"
     const val readMenuLensRadius = "readMenuLensRadius"
     const val readMenuBorderWidth = "readMenuBorderWidth"

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

@@ -113,6 +113,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         where type & ${BookType.text} > 0
@@ -154,6 +155,7 @@ interface BookDao {
         canUpdate,
         ifnull(customIntro, intro) as intro,
         kind,
+        customTag,
         wordCount
     FROM books
     WHERE $PUBLIC_BOOK_FILTER
@@ -189,6 +191,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books
         WHERE type & ${BookType.audio} > 0
@@ -224,6 +227,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE type & ${BookType.local} > 0
@@ -264,6 +268,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         where type & ${BookType.audio} = 0 and type & ${BookType.local} = 0
@@ -305,6 +310,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         where type & ${BookType.local} > 0
@@ -341,6 +347,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE (`group` & :group) > 0
@@ -378,9 +385,11 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         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
         """
     )
@@ -413,6 +422,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         where type & ${BookType.updateError} > 0 
@@ -449,6 +459,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE durChapterIndex = 0 AND durChapterPos = 0
@@ -484,6 +495,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
@@ -521,6 +533,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
@@ -558,6 +571,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
@@ -593,6 +607,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
@@ -628,6 +643,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE type & ${BookType.image} > 0
@@ -663,6 +679,7 @@ interface BookDao {
             canUpdate,
             ifnull(customIntro, intro) as intro,
             kind,
+            customTag,
             wordCount
         FROM books 
         WHERE type & ${BookType.text} > 0
@@ -875,7 +892,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE $PUBLIC_BOOK_FILTER
         ORDER BY durChapterTime DESC
@@ -891,7 +908,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.text} > 0 AND type & ${BookType.local} = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -910,7 +927,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.local} > 0
             AND $PUBLIC_BOOK_FILTER
@@ -927,7 +944,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.audio} > 0
             AND $PUBLIC_BOOK_FILTER
@@ -944,7 +961,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.audio} = 0 AND type & ${BookType.local} = 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -962,7 +979,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.local} > 0
             AND ($PUBLIC_GROUP_MASK & `group`) = 0
@@ -980,7 +997,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.image} > 0
             AND $PUBLIC_BOOK_FILTER
@@ -997,7 +1014,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.text} > 0
             AND $PUBLIC_BOOK_FILTER
@@ -1014,7 +1031,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE type & ${BookType.updateError} > 0
             AND $PUBLIC_BOOK_FILTER
@@ -1031,7 +1048,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE durChapterIndex = 0 AND durChapterPos = 0
             AND $PUBLIC_BOOK_FILTER
@@ -1048,7 +1065,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex > 0 AND durChapterIndex < totalChapterNum - 1
             AND $PUBLIC_BOOK_FILTER
@@ -1065,7 +1082,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1
             AND $PUBLIC_BOOK_FILTER
@@ -1082,7 +1099,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 1
             AND $PUBLIC_BOOK_FILTER
@@ -1099,7 +1116,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE totalChapterNum > 0 AND durChapterIndex >= totalChapterNum - 1 AND canUpdate = 0
             AND $PUBLIC_BOOK_FILTER
@@ -1116,7 +1133,7 @@ interface BookDao {
             durChapterPos, latestChapterTitle, latestChapterTime,
             lastCheckCount, totalChapterNum, durChapterIndex,
             type, `group`, `order`, canUpdate,
-            ifnull(customIntro, intro) as intro, kind, wordCount
+            ifnull(customIntro, intro) as intro, kind, customTag, wordCount
         FROM books
         WHERE (`group` & :groupId) > 0
             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 HOME_SOURCE_SET_URL = stringPreferencesKey("home_source_set_url")
     val HOME_DASHBOARD_SECTIONS = stringPreferencesKey("home_dashboard_sections")
+    val READ_RECORD_DISPLAY_MODE = stringPreferencesKey("readRecordDisplayMode")
     val COVER_ALBUM_MIGRATED = booleanPreferencesKey("cover_album_migrated")
     val SELECTED_COVER_ALBUM_ID = stringPreferencesKey("selected_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.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.catch
@@ -110,14 +111,10 @@ class ReadAloudSettingsRepository(
     }
 
     suspend fun setTtsTimer(value: Int) {
-        val timer = value.coerceIn(0, 180)
+        val timer = PlaybackTimer.normalize(value)
         ReadConfig.ttsTimer = timer
     }
 
-    suspend fun saveTtsTimer(value: Int) {
-        settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180))
-    }
-
     suspend fun setTtsFollowSys(value: Boolean) {
         ReadConfig.ttsFollowSys = value
     }
@@ -154,7 +151,7 @@ class ReadAloudSettingsRepository(
             systemMediaControlCompatibilityChange =
                 this[Keys.SystemMediaControlCompatibilityChange] ?: true,
             streamReadAloudAudio = this[Keys.StreamReadAloudAudio] ?: false,
-            ttsTimer = this[Keys.TtsTimer] ?: 0,
+            ttsTimer = PlaybackTimer.normalize(this[Keys.TtsTimer] ?: 0),
             ttsFollowSys = this[Keys.TtsFollowSys] ?: true,
             ttsSpeechRate = this[Keys.TtsSpeechRate] ?: 5,
             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 expandTextMenu: Boolean = false,
     val showSelectMenuIcon: Boolean = true,
-    val textSelectMenuFilter: String = "",
     val showReadTitleAddition: Boolean = true,
     val autoReadSpeed: Int = 10,
     val prevKeys: String = "",
@@ -81,9 +80,12 @@ data class ReadPreferences(
     val readMenuBgColorNight: Int = 0,
     val readMenuAccentColorNight: Int = 0,
     val readMenuContainerColorNight: Int = 0,
+    val readMenuTextColor: Int = 0,
+    val readMenuTextColorNight: Int = 0,
     val readMenuColorMode: Int = 1,
     val readMenuIconShowText: Boolean = true,
     val readMenuIconStyle: Int = 0,
+    val titleBarIconStyle: Int = 0,
     val readMenuIconItemsPerRow: Int = 5,
     val readMenuIconRowCount: Int = 1,
     val readMenuBottomCornerRadius: Int = 0,
@@ -98,6 +100,7 @@ data class ReadPreferences(
     val readMenuBlurRadius: Int = 24,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurColor: Int = 0,
+    val readMenuBlurColorNight: Int = 0,
     val readMenuPaletteStyle: String = "",
     val readMenuLensRadius: Float = 24f,
     val readMenuBorderWidth: Int = 0,
@@ -239,9 +242,6 @@ class ReadSettingsRepository(
     suspend fun setShowSelectMenuIcon(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.showSelectMenuIcon, value)
 
-    suspend fun setTextSelectMenuFilter(value: String) =
-        settingsRepository.putString(PreferKey.textSelectMenuFilter, value)
-
     suspend fun setShowReadTitleAddition(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.showReadTitleAddition, value)
 
@@ -302,6 +302,12 @@ class ReadSettingsRepository(
     suspend fun setReadMenuContainerColorNight(value: Int) =
         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) =
         settingsRepository.putInt(PreferKey.readMenuColorMode, value.coerceIn(0, 1))
 
@@ -311,6 +317,9 @@ class ReadSettingsRepository(
     suspend fun setReadMenuIconStyle(value: Int) =
         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) =
         settingsRepository.putInt(PreferKey.readMenuIconItemsPerRow, value.coerceIn(2, 8))
 
@@ -353,6 +362,9 @@ class ReadSettingsRepository(
     suspend fun setReadMenuBlurColor(value: Int) =
         settingsRepository.putInt(PreferKey.readMenuBlurColor, value)
 
+    suspend fun setReadMenuBlurColorNight(value: Int) =
+        settingsRepository.putInt(PreferKey.readMenuBlurColorNight, value)
+
     suspend fun setReadMenuPaletteStyle(value: String) =
         settingsRepository.putString(PreferKey.readMenuPaletteStyle, value)
 
@@ -434,7 +446,6 @@ class ReadSettingsRepository(
             disableReturnKey = this[Keys.DisableReturnKey] ?: false,
             expandTextMenu = this[Keys.ExpandTextMenu] ?: false,
             showSelectMenuIcon = this[Keys.ShowSelectMenuIcon] ?: true,
-            textSelectMenuFilter = this[Keys.TextSelectMenuFilter] ?: "",
             showReadTitleAddition = this[Keys.ShowReadTitleAddition] ?: true,
             autoReadSpeed = this[Keys.AutoReadSpeed] ?: 10,
             prevKeys = this[Keys.PrevKeys] ?: "",
@@ -462,9 +473,12 @@ class ReadSettingsRepository(
             readMenuBgColorNight = this[Keys.ReadMenuBgColorNight] ?: 0,
             readMenuAccentColorNight = this[Keys.ReadMenuAccentColorNight] ?: 0,
             readMenuContainerColorNight = this[Keys.ReadMenuContainerColorNight] ?: 0,
+            readMenuTextColor = this[Keys.ReadMenuTextColor] ?: 0,
+            readMenuTextColorNight = this[Keys.ReadMenuTextColorNight] ?: 0,
             readMenuColorMode = this[Keys.ReadMenuColorMode] ?: 1,
             readMenuIconShowText = this[Keys.ReadMenuIconShowText] ?: true,
             readMenuIconStyle = this[Keys.ReadMenuIconStyle] ?: 0,
+            titleBarIconStyle = this[Keys.TitleBarIconStyle] ?: 0,
             readMenuIconItemsPerRow = this[Keys.ReadMenuIconItemsPerRow] ?: 5,
             readMenuIconRowCount = this[Keys.ReadMenuIconRowCount] ?: 1,
             readMenuBottomCornerRadius = this[Keys.ReadMenuBottomCornerRadius] ?: 0,
@@ -483,6 +497,7 @@ class ReadSettingsRepository(
             readMenuBlurRadius = this[Keys.ReadMenuBlurRadius] ?: 24,
             readMenuBlurAlpha = this[Keys.ReadMenuBlurAlpha] ?: 60,
             readMenuBlurColor = this[Keys.ReadMenuBlurColor] ?: 0,
+            readMenuBlurColorNight = this[Keys.ReadMenuBlurColorNight] ?: 0,
             readMenuPaletteStyle = this[Keys.ReadMenuPaletteStyle] ?: "",
             readMenuLensRadius = this[Keys.ReadMenuLensRadius] ?: 24f,
             readMenuBorderWidth = this[Keys.ReadMenuBorderWidth] ?: 0,
@@ -535,7 +550,6 @@ class ReadSettingsRepository(
         val DisableReturnKey = booleanPreferencesKey(PreferKey.disableReturnKey)
         val ExpandTextMenu = booleanPreferencesKey(PreferKey.expandTextMenu)
         val ShowSelectMenuIcon = booleanPreferencesKey(PreferKey.showSelectMenuIcon)
-        val TextSelectMenuFilter = stringPreferencesKey(PreferKey.textSelectMenuFilter)
         val ShowReadTitleAddition = booleanPreferencesKey(PreferKey.showReadTitleAddition)
         val AutoReadSpeed = intPreferencesKey(PreferKey.autoReadSpeed)
         val PrevKeys = stringPreferencesKey(PreferKey.prevKeys)
@@ -563,9 +577,12 @@ class ReadSettingsRepository(
         val ReadMenuBgColorNight = intPreferencesKey(PreferKey.readMenuBgColorNight)
         val ReadMenuAccentColorNight = intPreferencesKey(PreferKey.readMenuAccentColorNight)
         val ReadMenuContainerColorNight = intPreferencesKey(PreferKey.readMenuContainerColorNight)
+        val ReadMenuTextColor = intPreferencesKey(PreferKey.readMenuTextColor)
+        val ReadMenuTextColorNight = intPreferencesKey(PreferKey.readMenuTextColorNight)
         val ReadMenuColorMode = intPreferencesKey(PreferKey.readMenuColorMode)
         val ReadMenuIconShowText = booleanPreferencesKey(PreferKey.readMenuIconShowText)
         val ReadMenuIconStyle = intPreferencesKey(PreferKey.readMenuIconStyle)
+        val TitleBarIconStyle = intPreferencesKey(PreferKey.titleBarIconStyle)
         val ReadMenuIconItemsPerRow = intPreferencesKey(PreferKey.readMenuIconItemsPerRow)
         val ReadMenuIconRowCount = intPreferencesKey(PreferKey.readMenuIconRowCount)
         val ReadMenuBottomCornerRadius = intPreferencesKey(PreferKey.readMenuBottomCornerRadius)
@@ -583,6 +600,7 @@ class ReadSettingsRepository(
         val ReadMenuBlurRadius = intPreferencesKey(PreferKey.readMenuBlurRadius)
         val ReadMenuBlurAlpha = intPreferencesKey(PreferKey.readMenuBlurAlpha)
         val ReadMenuBlurColor = intPreferencesKey(PreferKey.readMenuBlurColor)
+        val ReadMenuBlurColorNight = intPreferencesKey(PreferKey.readMenuBlurColorNight)
         val ReadMenuPaletteStyle = stringPreferencesKey(PreferKey.readMenuPaletteStyle)
         val ReadMenuLensRadius = floatPreferencesKey(PreferKey.readMenuLensRadius)
         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())
     }
 
+    fun clearSession(bookUrl: String) {
+        if (lastSearchSession?.bookUrl == bookUrl) {
+            lastSearchResults = null
+            lastQueryKey = null
+            lastSearchSession = null
+        }
+    }
+
     fun getCache(
         bookUrl: 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 androidx.core.content.edit
+import androidx.datastore.core.DataMigration
 import androidx.datastore.core.DataStore
 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 io.legado.app.constant.PreferKey
 import io.legado.app.utils.defaultSharedPreferences
@@ -22,11 +31,38 @@ val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
             SharedPreferencesMigration(
                 context,
                 "${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 为准。

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

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

@@ -61,7 +61,10 @@ class ExploreBooksUseCase(
     ): ExploreRequest {
         val base = gateway.getBookSource(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
             ?: (if (key != null) source.searchUrl else null)
             ?: 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 containsBooks = LinkedHashMap<SearchBookKey, SearchBook>()
         private val otherBooks = LinkedHashMap<SearchBookKey, SearchBook>()
+        private val bookUrlIndex = HashMap<String, SearchBookKey>()
         var resultLimitReached = false
             private set
 
         val count: Int
             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 {
             if (newBooks.isEmpty()) return SearchBookChange()
 
@@ -248,11 +252,26 @@ class SearchBooksUseCase(
             val touchedBuckets = linkedSetOf<LinkedHashMap<SearchBookKey, SearchBook>>()
             newBooks.forEach { newBook ->
                 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 key = SearchBookKey(newBook.name, newBook.author)
                 val currentBook = bucket[key]
                 if (currentBook == null) {
                     bucket[key] = newBook
+                    bookUrlIndex[newBook.bookUrl] = key
                     upsertBooks.add(newBook)
                 } else {
                     currentBook.addOrigin(newBook.origin)
@@ -261,6 +280,7 @@ class SearchBooksUseCase(
                 touchedBuckets.add(bucket)
                 trimSearchBooks()?.let { removed ->
                     removedBookUrls.add(removed.bookUrl)
+                    bookUrlIndex.remove(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 降序重新排列桶内元素。
          * 使用 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.BookSource
 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.help.RuleBigDataHelp
 import io.legado.app.help.config.AppConfig
 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.utils.FileDoc
 import io.legado.app.utils.GSON
@@ -122,9 +122,19 @@ fun Book.contains(word: String?): Boolean {
             || originName.contains(word)
             || origin.contains(word)
             || kind?.contains(word) == true
+            || customTag?.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未被其他书占用,或判定为同一本书时,允许迁移主键。
  */
@@ -439,7 +449,7 @@ fun applyTagGroupRules(
 
     val updatedBooks = mutableListOf<Book>()
     for (book in books) {
-        val kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
+        val kinds = book.getDisplayTagList()
         var newGroupMask = 0L
         for ((rule, regex) in compiledRules) {
             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 kinds = book.kind?.splitNotBlank(",", "\n").orEmpty()
+    val kinds = book.getDisplayTagList()
     var newGroupMask = 0L
     for ((rule, regex) in compiledRules) {
         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.data.appDb
 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.bookshelfConfig.BookshelfConfig
 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.sysConfiguration
 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
 
 @Suppress("MemberVisibilityCanBePrivate", "ConstPropertyName")
 object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
+    private val settingsRepository: SettingsRepository by lazy {
+        get(SettingsRepository::class.java)
+    }
     val isCronet get() = DownloadCacheConfig.cronetEnable
     val useAntiAlias get() = OtherConfig.antiAlias
     val userAgent: String get() = DownloadCacheConfig.userAgent
@@ -290,16 +297,20 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
 
     var bookExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.bookExportFileName)
+        get() = runBlocking {
+            settingsRepository.getString(PreferKey.bookExportFileName).first().ifEmpty { null }
+        }
         set(value) {
-            appCtx.putPrefString(PreferKey.bookExportFileName, value)
+            runBlocking { DsSync.putString(PreferKey.bookExportFileName, value) }
         }
 
     // 保存 自定义导出章节模式 文件名js表达式
     var episodeExportFileName: String?
-        get() = appCtx.getPrefString(PreferKey.episodeExportFileName, "")
+        get() = runBlocking {
+            settingsRepository.getString(PreferKey.episodeExportFileName).first()
+        }
         set(value) {
-            appCtx.putPrefString(PreferKey.episodeExportFileName, value)
+            runBlocking { DsSync.putString(PreferKey.episodeExportFileName, value) }
         }
 
     var bookImportFileName: String?
@@ -504,12 +515,16 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             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
-        get() = appCtx.getPrefBoolean(PreferKey.importKeepEnable, false)
+        get() = runBlocking { settingsRepository.getBoolean(PreferKey.importKeepEnable).first() }
         set(value) {
-            appCtx.putPrefBoolean(PreferKey.importKeepEnable, value)
+            runBlocking { DsSync.putBoolean(PreferKey.importKeepEnable, value) }
         }
 
     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 bgMeanColor: Int = 0
     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 textShadowColor: Int get() = durConfig.curTextShadowColor()
     val menuColor: Int get() = readMenuAccentColor
@@ -211,6 +217,8 @@ object ReadBookConfig {
     var readMenuBottomBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuBottomBarLiquidGlassButtons, false)
     var readMenuBorderColor by prefDelegate(PreferKey.readMenuBorderColor, 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 readSliderMode by prefDelegate(PreferKey.readSliderMode, "0")
     var showBrightnessView by prefDelegate(PreferKey.showBrightnessView, "1")
@@ -230,6 +238,7 @@ object ReadBookConfig {
 
     var readMenuColorMode by clampedPrefDelegate(PreferKey.readMenuColorMode, 1, 0..1)
     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 readMenuIconRowCount by clampedPrefDelegate(PreferKey.readMenuIconRowCount, 1, 1..2)
     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 readMenuBlurAlpha by clampedPrefDelegate(PreferKey.readMenuBlurAlpha, 60, 0..100)
     var readMenuBlurColor by prefDelegate(PreferKey.readMenuBlurColor, 0)
+    var readMenuBlurColorNight by prefDelegate(PreferKey.readMenuBlurColorNight, 0)
     var readMenuPaletteStyle: String = ""
     var readMenuLensRadius by clampedPrefDelegate(PreferKey.readMenuLensRadius, 24f, 0f..48f)
     var readMenuBorderWidth by clampedPrefDelegate(PreferKey.readMenuBorderWidth, 0, 0..4)
@@ -343,6 +353,12 @@ object ReadBookConfig {
     val resolvedMenuBorderColor: Int
         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
 
@@ -533,6 +549,15 @@ object ReadBookConfig {
             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
         get() = config.paragraphIndent
         set(value) {
@@ -745,12 +770,30 @@ object ReadBookConfig {
             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
         get() = config.tipFooterColor
         set(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
         get() = config.tipDividerColor
         set(value) {
@@ -803,6 +846,7 @@ object ReadBookConfig {
             exportConfig.titleTopSpacing = shareConfig.titleTopSpacing
             exportConfig.titleBottomSpacing = shareConfig.titleBottomSpacing
             exportConfig.titleColor = shareConfig.titleColor
+            exportConfig.titleColorNight = shareConfig.titleColorNight
             exportConfig.paddingBottom = shareConfig.paddingBottom
             exportConfig.paddingLeft = shareConfig.paddingLeft
             exportConfig.paddingRight = shareConfig.paddingRight
@@ -824,7 +868,9 @@ object ReadBookConfig {
             exportConfig.tipFooterMiddle = shareConfig.tipFooterMiddle
             exportConfig.tipFooterRight = shareConfig.tipFooterRight
             exportConfig.tipHeaderColor = shareConfig.tipHeaderColor
+            exportConfig.tipHeaderColorNight = shareConfig.tipHeaderColorNight
             exportConfig.tipFooterColor = shareConfig.tipFooterColor
+            exportConfig.tipFooterColorNight = shareConfig.tipFooterColorNight
             exportConfig.headerMode = shareConfig.headerMode
             // MD3专有属性
             exportConfig.footerMode = shareConfig.footerMode
@@ -916,6 +962,7 @@ object ReadBookConfig {
         var titleTopSpacing: Int = 0,
         var titleBottomSpacing: Int = 0,
         var titleColor: Int = 0,
+        var titleColorNight: Int = 0,
         var titleBold: Int = 500,//是否粗体字 0:正常, 1:粗体, 2:细体
         var titleLineSpacingExtra: Int = 12,
         var titleLineSpacingSub: Int = 12,
@@ -954,7 +1001,9 @@ object ReadBookConfig {
         var tipFooterMiddle: Int = tipNone,
         var tipFooterRight: Int = tipPageAndTotal,
         var tipHeaderColor: Int = 0,
+        var tipHeaderColorNight: Int = 0,
         var tipFooterColor: Int = 0,
+        var tipFooterColorNight: Int = 0,
         var tipDividerColor: Int = -1,
         var headerMode: Int = 0,
         var footerMode: Int = 0,
@@ -1065,6 +1114,7 @@ object ReadBookConfig {
             "titleTopSpacing" to titleTopSpacing,
             "titleBottomSpacing" to titleBottomSpacing,
             "titleColor" to titleColor,
+            "titleColorNight" to titleColorNight,
             "paragraphIndent" to paragraphIndent,
             "paddingBottom" to paddingBottom,
             "paddingLeft" to paddingLeft,
@@ -1087,7 +1137,9 @@ object ReadBookConfig {
             "tipFooterMiddle" to tipFooterMiddle,
             "tipFooterRight" to tipFooterRight,
             "tipHeaderColor" to tipHeaderColor,
+            "tipHeaderColorNight" to tipHeaderColorNight,
             "tipFooterColor" to tipFooterColor,
+            "tipFooterColorNight" to tipFooterColorNight,
             "tipDividerColor" to tipDividerColor,
             "headerMode" to headerMode,
             "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))
     }
 
+    /**
+     * 切换深浅色但不发 RECREATE:Compose 界面通过 ThemeConfig.themeMode 快照状态
+     * 自动换色(保留 ToggleButton 等组件的过渡动画),setDefaultNightMode 只会重建
+     * 未在 manifest 声明 uiMode configChanges 的旧 View Activity
+     */
+    fun applyDayNightLive() {
+        initNightMode()
+        postEvent(EventBus.UP_CONFIG, arrayListOf(2))
+    }
+
     fun applyDayNightInit(context: Context) {
         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.JsonParser
 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.themeConfig.ThemeConfig
 import io.legado.app.utils.EncoderUtils
@@ -22,100 +23,11 @@ import java.io.FileOutputStream
  */
 object ThemeImportExport {
 
-    private const val DIR_NAME = "saved_themes"
-    private val baseDir get() = File(appCtx.filesDir, DIR_NAME)
-
     private val EXPORT_GSON = GsonBuilder()
         .setPrettyPrinting()
         .disableHtmlEscaping()
         .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,
             bookInfoFollowCoverColor = ThemeConfig.bookInfoFollowCoverColor,
             bookInfoBackgroundBlur = ThemeConfig.bookInfoBackgroundBlur,
+            bookInfoNetworkCoverBackground = ThemeConfig.bookInfoNetworkCoverBackground,
+            bookInfoDefaultCoverBackground = ThemeConfig.bookInfoDefaultCoverBackground,
 
             // 容器设置
             containerOpacity = ThemeConfig.containerOpacity,
@@ -180,6 +94,10 @@ object ThemeImportExport {
             enableCustomTagColors = ThemeConfig.enableCustomTagColors,
             customTagColorsJson = ThemeConfig.customTagColorsJson,
 
+            // 书架卡片颜色
+            bookshelfCardColor = BookshelfConfig.bookshelfCardColor,
+            bookshelfCardColorDark = BookshelfConfig.bookshelfCardColorDark,
+
             // 主界面设置
             showHome = ThemeConfig.showHome,
             showDiscovery = ThemeConfig.showDiscovery,
@@ -335,6 +253,10 @@ object ThemeImportExport {
         ThemeConfig.bookInfoInputColor = data.bookInfoInputColor
         ThemeConfig.bookInfoFollowCoverColor = data.bookInfoFollowCoverColor
         ThemeConfig.bookInfoBackgroundBlur = data.bookInfoBackgroundBlur
+        ThemeConfig.bookInfoNetworkCoverBackground =
+            data.bookInfoNetworkCoverBackground ?: data.bookInfoBackgroundBlur
+        ThemeConfig.bookInfoDefaultCoverBackground =
+            data.bookInfoDefaultCoverBackground ?: data.bookInfoBackgroundBlur
 
         // 容器设置
         ThemeConfig.containerOpacity = data.containerOpacity
@@ -362,6 +284,10 @@ object ThemeImportExport {
         ThemeConfig.enableCustomTagColors = data.enableCustomTagColors
         ThemeConfig.customTagColorsJson = data.customTagColorsJson
 
+        // 书架卡片颜色
+        BookshelfConfig.bookshelfCardColor = data.bookshelfCardColor
+        BookshelfConfig.bookshelfCardColorDark = data.bookshelfCardColorDark
+
         // 主界面设置
         ThemeConfig.showHome = data.showHome
         ThemeConfig.showDiscovery = data.showDiscovery
@@ -552,6 +478,8 @@ object ThemeImportExport {
         }
     }
 
+    internal fun parseLegacyThemeData(json: String): ThemeExportData? = parseThemeData(json)
+
     /**
      * 兼容曾由 R8 混淆字段名导出的主题配置。
      * 该映射对应加入首页开关、但尚未加入导航顺序和日夜独立配色的旧格式。
@@ -725,6 +653,8 @@ data class ThemeExportData(
     val bookInfoInputColor: Int = 0,
     val bookInfoFollowCoverColor: Boolean = true,
     val bookInfoBackgroundBlur: String = ThemeConfig.BOOK_INFO_BACKGROUND_BLUR_ON,
+    val bookInfoNetworkCoverBackground: String? = null,
+    val bookInfoDefaultCoverBackground: String? = null,
 
     // 容器设置
     val containerOpacity: Int = 100,
@@ -752,6 +682,10 @@ data class ThemeExportData(
     val enableCustomTagColors: Boolean = false,
     val customTagColorsJson: String? = null,
 
+    // 书架卡片颜色
+    val bookshelfCardColor: Int = 0,
+    val bookshelfCardColorDark: Int = 0,
+
     // 主界面设置
     val showHome: 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) {
         savedThemesRoot.mkdirs()
-        val folderThemes = savedThemesRoot
+        savedThemesRoot
             .listFiles()
             .orEmpty()
             .filter(File::isDirectory)
+            .filterNot { it.name.endsWith(".tmp") }
             .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(
         name: String,
         data: ThemeExportData? = null,
@@ -210,43 +236,22 @@ class ThemePackageManager(
                 error("Failed to delete saved theme: ${theme.name}")
             }
 
-            ThemeImportExport.deleteSavedTheme(theme)
         }
     }
 
     suspend fun applySavedTheme(theme: SavedTheme): Result<Unit> =
         withContext(Dispatchers.IO) {
             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) {
                 val extension = sourceExtension(path, source.key)
                 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)
@@ -698,13 +709,20 @@ class ThemePackageManager(
         selectedCoverAlbumId: String?,
     ) {
         saveTheme(
-            name = ThemeImportExport.uniqueSavedThemeName(name),
+            name = uniqueSavedThemeName(name),
             data = ThemeImportExport.exportFromCurrent().copy(
                 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(
         uri: Uri,
         packageName: String? = null,
@@ -922,7 +940,20 @@ class ThemePackageManager(
         val key: String,
         val sourcePath: 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(
         val albums: List<ThemePackageCoverAlbum>,
@@ -930,6 +961,11 @@ class ThemePackageManager(
     )
 }
 
+data class LegacyThemeMigrationResult(
+    val migratedCount: Int,
+    val failedCount: Int,
+)
+
 @Keep
 data class ThemePackageManifest(
     @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.WebViewClient
 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.help.CacheManager
+import io.legado.app.help.WebCacheManager
 import io.legado.app.help.config.AppConfig
 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 kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Runnable
@@ -43,13 +52,17 @@ class BackstageWebView(
     private val overrideUrlRegex: String? = null,
     private val javaScript: String? = null,
     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 var callback: Callback? = null
     private var mWebView: WebView? = null
 
-    suspend fun getStrResponse(): StrResponse = withTimeout(60000L) {
+    suspend fun getStrResponse(): StrResponse = withTimeout(timeout ?: 60000L) {
         suspendCancellableCoroutine { block ->
             block.invokeOnCancellation {
                 runOnUI {
@@ -88,9 +101,22 @@ class BackstageWebView(
         mWebView = webView
         try {
             when {
-                !html.isNullOrEmpty() -> if (url.isNullOrEmpty()) {
+                !html.isNullOrEmpty() -> if (url.isNullOrEmpty() && !isRule) {
                     webView.loadData(html, "text/html", getEncoding())
                 } 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)
                 }
 
@@ -114,6 +140,11 @@ class BackstageWebView(
         settings.blockNetworkImage = true
         settings.userAgentString = headerMap?.get(AppConst.UA_NAME) ?: AppConfig.userAgent
         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()) {
             webView.webViewClient = HtmlWebViewClient()
         } else {
@@ -164,6 +195,12 @@ class BackstageWebView(
 
         override fun onPageFinished(view: WebView, url: String) {
             setCookie(url)
+            result?.let {
+                view.evaluateJavascript(
+                    "window.result = $nameCache.getFromMemory('webview_result')",
+                    null,
+                )
+            }
             if (runnable == null) {
                 runnable = EvalJsRunnable(view, url, getJs())
             }
@@ -183,10 +220,15 @@ class BackstageWebView(
         private inner class EvalJsRunnable(
             webView: WebView,
             private val url: String,
-            private val mJavaScript: String
+            mJavaScript: String
         ) : Runnable {
             var retry = 0
             private val mWebView: WeakReference<WebView> = WeakReference(webView)
+            private val mJavaScript = if (isRule) {
+                "$getInjectionString\n$mJavaScript"
+            } else {
+                mJavaScript
+            }
             override fun run() {
                 mWebView.get()?.evaluateJavascript(mJavaScript) {
                     handleResult(it)
@@ -331,4 +373,4 @@ class BackstageWebView(
         abstract fun onResult(response: StrResponse)
         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
 
 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.bookInfoFollowCoverColor,
         PreferKey.bookInfoBackgroundBlur,
+        PreferKey.bookInfoNetworkCoverBackground,
+        PreferKey.bookInfoDefaultCoverBackground,
         PreferKey.cBackground,
         PreferKey.cBBackground,
         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) {
         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 {
             map.forEach { (key, value) ->
                 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
         settingsRepository.batchPutFromMap(finalMap)

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

@@ -27,7 +27,7 @@ enum class AppVariant {
 @Keep
 data class GithubRelease(
     val assets: List<Asset>?,
-    val body: String,
+    val body: String?,
     @SerializedName("prerelease")
     val isPreRelease: Boolean,
     @SerializedName("tag_name")
@@ -36,26 +36,44 @@ data class GithubRelease(
     @SerializedName("created_at")
     val createdAt: String?
 ) {
-    fun gitReleaseToAppReleaseInfo(): List<AppReleaseInfo> {
+    fun gitReleaseToAppReleaseInfo(
+        supportedAbis: List<String> = Build.SUPPORTED_ABIS.asList()
+    ): List<AppReleaseInfo> {
         assets ?: throw NoStackTraceException("获取新版本出错")
 
         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
 data class Asset(
     @SerializedName("browser_download_url")
@@ -89,4 +107,4 @@ data class Asset(
             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.fromJsonArray
 import io.legado.app.utils.fromJsonObject
+import kotlinx.coroutines.CancellationException
 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 {
 
+    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
         get() = when (AppConfig.updateToVariant) {
             "official_version" -> AppVariant.OFFICIAL
@@ -22,55 +35,96 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
             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}") }
-
-                releases.flatMap { it.gitReleaseToAppReleaseInfo() }
-                    .sortedByDescending { it.createdAt }
-            }
-
+            )
+            AppVariant.BETA_RELEASE -> parseReleaseList(body)
+                .filter { it.isPreRelease }
             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
 
         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> {
         return Coroutine.async(scope) {
             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
             } else {
-                releases.filter { it.appVariant == checkVariant }
+                releases.filter { it.appVariant == variant }
             }
 
             val latest = filtered.firstOrNull { r ->
@@ -165,4 +265,40 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
     fun String.versionCompare(other: String): Int {
         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.res.ColorStateList
 import android.graphics.Color
-import android.os.Handler
-import android.os.Looper
 import android.util.AttributeSet
 import android.view.ContextThemeWrapper
 import android.view.LayoutInflater
@@ -20,11 +18,9 @@ import com.google.android.material.card.MaterialCardView
 import io.legado.app.R
 import io.legado.app.constant.EventBus
 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.utils.getPrefString
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.restart
 import io.legado.app.utils.toastOnUi
 import splitties.init.appCtx
 
@@ -98,29 +94,12 @@ class ThemeCardPreference(context: Context, attrs: AttributeSet) : Preference(co
                             ThemeConfig.containerOpacity = 0
                         }
                     }
-                    val oldValue = currentValue
                     currentValue = value
                     persistString(value)
                     callChangeListener(value)
                     ThemeConfig.appTheme = value
                     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.BookChapter
 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.getBookSource
 import io.legado.app.help.book.readSimulating
@@ -335,14 +336,15 @@ object AudioPlay : CoroutineScope by MainScope() {
     }
 
     fun setTimer(minute: Int) {
+        val timer = PlaybackTimer.normalize(minute)
         if (AudioPlayService.isRun) {
             val intent = Intent(context, AudioPlayService::class.java)
             intent.action = IntentAction.setTimer
-            intent.putExtra("minute", minute)
+            intent.putExtra("minute", timer)
             context.startService(intent)
         } 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 canceledDownloadSet = hashSetOf<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 tasks = CompositeCoroutine()
     private val repository = CacheDownloadRepository()
@@ -671,7 +671,8 @@ class CacheBookModel(
         scope: CoroutineScope,
         chapter: BookChapter,
         semaphore: Semaphore?,
-        resetPageOffset: Boolean = false
+        resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ): Boolean {
         if (!markChapterDownloadStarted(chapter.index)) {
             // Chapter is already in onDownloadSet. Check if the task is actually alive.
@@ -686,11 +687,19 @@ class CacheBookModel(
                 }
                 notifyDownloadSetChanged()
                 if (!markChapterDownloadStarted(chapter.index)) {
-                    markPendingReadRequest(chapter.index, resetPageOffset)
+                    markPendingReadRequest(
+                        chapter.index,
+                        resetPageOffset,
+                        preserveReadAloudPosition,
+                    )
                     return false
                 }
             } else {
-                markPendingReadRequest(chapter.index, resetPageOffset)
+                markPendingReadRequest(
+                    chapter.index,
+                    resetPageOffset,
+                    preserveReadAloudPosition,
+                )
                 return true
             }
         }
@@ -712,17 +721,33 @@ class CacheBookModel(
             onSuccess(chapter)
             ReadBook.downloadedChapters.add(chapter.index)
             ReadBook.downloadFailChapters.remove(chapter.index)
-            downloadFinish(chapter, content, resetPageOffset)
+            downloadFinish(
+                chapter,
+                content,
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadContent(chapter, content)
         }.onError {
             onError(chapter, it)
             ReadBook.downloadFailChapters[chapter.index] =
                 (ReadBook.downloadFailChapters[chapter.index] ?: 0) + 1
-            downloadFinish(chapter, "获取正文失败\n${it.localizedMessage}", resetPageOffset)
+            downloadFinish(
+                chapter,
+                "获取正文失败\n${it.localizedMessage}",
+                resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
             emitPendingReadError(chapter, it)
         }.onCancel {
             onCancel(chapter.index, requeue = false)
-            downloadFinish(chapter, "download canceled", resetPageOffset, true)
+            downloadFinish(
+                chapter,
+                "download canceled",
+                resetPageOffset,
+                canceled = true,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            )
         }.onFinally {
             chapterTasks.remove(chapter.index)
             host.onTaskQueuesChanged(book.bookUrl)
@@ -744,35 +769,59 @@ class CacheBookModel(
     }
 
     @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
-    private fun consumePendingReadRequest(index: Int): Boolean? {
+    private fun consumePendingReadRequest(index: Int): PendingReadRequest? {
         return pendingReadRequestMap.remove(index)
     }
 
     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) {
-        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) {
-        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(
         chapter: BookChapter,
         content: String,
         resetPageOffset: Boolean = false,
-        canceled: Boolean = false
+        canceled: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
     ) {
         if (ReadBook.book?.bookUrl == book.bookUrl) {
             ReadBook.contentLoadFinish(
@@ -781,7 +830,23 @@ class CacheBookModel(
                 content = content,
                 resetPageOffset = resetPageOffset,
                 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.domain.model.readaloud.ReadAloudVoice
 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.HttpReadAloudService
 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) {
         if (BaseReadAloudService.isRun) {
             val intent = Intent(context, aloudClass)
             intent.action = IntentAction.setTimer
-            intent.putExtra("minute", minute)
+            intent.putExtra("minute", PlaybackTimer.normalize(minute))
             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 nextChapterLoadingLock = Mutex()
     var readStartTime: Long = System.currentTimeMillis()
+    var isUiActive = false
+    val isAutoSaveSessionRunning: Boolean
+        get() = autoSaveJob != 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() {
-        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() {
-        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() {
-        autoSaveJob?.cancel()
-        autoSaveJob = null
+        synchronized(this) {
+            autoSaveJob?.cancel()
+            autoSaveJob = null
+        }
     }
 
     fun commitReadSession() {
-        val sessionToCommit = currentActiveSession ?: return
-        currentActiveSession = null
+        val sessionToCommit = synchronized(this) {
+            val session = currentActiveSession
+            currentActiveSession = null
+            session
+        } ?: return
         ioScope.launch {
             saveSessionToDb(sessionToCommit)
         }
@@ -388,7 +411,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      * 内部提交逻辑(auto-save 专用):保存后立即创建新 session 保证连续记录
      */
     private suspend fun commitSessionInternal() {
-        val sessionToSave = currentActiveSession ?: return
+        val sessionToSave = synchronized(this) { currentActiveSession } ?: return
         val sessionDuration = sessionToSave.endTime - sessionToSave.startTime
         if (sessionDuration < MIN_READ_DURATION) {
             return
@@ -400,11 +423,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             return
         }
         // 保存成功后立即创建新 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()
         curTextChapter?.let {
             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 {
-                    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(
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
     ) {
-        loadContent(durChapterIndex, resetPageOffset = resetPageOffset) {
+        loadContent(
+            durChapterIndex,
+            resetPageOffset = resetPageOffset,
+            preserveReadAloudPosition = preserveReadAloudPosition,
+        ) {
             success?.invoke()
         }
         loadContent(durChapterIndex + 1, resetPageOffset = resetPageOffset)
         loadContent(durChapterIndex - 1, resetPageOffset = resetPageOffset)
     }
 
+    fun relayoutContent() {
+        loadContent(
+            resetPageOffset = false,
+            preserveReadAloudPosition = true,
+        )
+    }
+
     fun loadOrUpContent(success: (() -> Unit)? = null) {
         val curChapter = curTextChapter
         if (curChapter == null || !curChapter.isLayoutSizeMatch()) {
+            val preserveReadAloudPosition = BaseReadAloudService.isRun &&
+                    BaseReadAloudService.currentChapterIndex == durChapterIndex
             curTextChapter = null
             nextTextChapter = null
             prevTextChapter = null
-            loadContent(durChapterIndex) {
+            loadContent(
+                durChapterIndex,
+                preserveReadAloudPosition = preserveReadAloudPosition,
+            ) {
                 success?.invoke()
             }
         } else {
@@ -707,6 +773,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         index: Int,
         upContent: Boolean = true,
         resetPageOffset: Boolean = false,
+        preserveReadAloudPosition: Boolean = false,
         success: (() -> Unit)? = null
     ) {
         Coroutine.async {
@@ -736,12 +803,14 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         it,
                         upContent,
                         resetPageOffset,
+                        preserveReadAloudPosition,
                         success = success
                     )
                 } ?: download(
                     downloadScope,
                     chapter,
-                    resetPageOffset
+                    resetPageOffset,
+                    preserveReadAloudPosition = preserveReadAloudPosition,
                 )
             }
         }.onError {
@@ -800,7 +869,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         } else {
             delay(1000)
             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,
         chapter: BookChapter,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         semaphore: Semaphore? = null,
         success: (() -> Unit)? = null
     ) {
@@ -819,7 +894,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val bookSource = bookSource
         if (bookSource != null) {
             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) {
                 removeLoading(chapter.index)
             }
@@ -830,6 +911,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 chapter,
                 "加载正文失败\n$msg",
                 resetPageOffset = resetPageOffset,
+                preserveReadAloudPosition = preserveReadAloudPosition,
                 success = success
             )
         }
@@ -896,6 +978,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         content: String,
         upContent: Boolean = true,
         resetPageOffset: Boolean,
+        preserveReadAloudPosition: Boolean = false,
         canceled: Boolean = false,
         success: (() -> Unit)? = null
     ) {
@@ -940,7 +1023,9 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                         callBack?.onLayoutPageCompleted(index, page)
                     }
                     if (upContent) callBack?.upContent(offset, !available && resetPageOffset)
-                    curPageChanged()
+                    curPageChanged(
+                        preserveReadAloudPosition = preserveReadAloudPosition,
+                    )
                     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 androidx.annotation.Keep
+import com.google.gson.internal.LinkedTreeMap
 import com.script.CompiledScript
 import com.script.buildScriptBindings
 import com.script.rhino.RhinoScriptEngine
 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.BaseSource
 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.help.CacheManager
 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.source.getShareScope
 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.GSONStrict
 import io.legado.app.utils.NetworkUtils
+import io.legado.app.utils.fromJsonArray
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getOrPutLimit
 import io.legado.app.utils.isDataUrl
 import io.legado.app.utils.isJson
+import io.legado.app.utils.isMainThread
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.splitNotBlank
 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)
                     }
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]
             } else {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
@@ -211,6 +235,9 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     if (rule.isNotEmpty()) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result).let {
+                                GSON.fromJsonArray<String>(it).getOrNull() ?: it
+                            }
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getStringList(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getStringList(rule)
@@ -290,6 +317,8 @@ class AnalyzeRule(
                 }?.let {
                     replaceRegex(it, sourceRule)
                 }
+            } else if (result is LinkedTreeMap<*, *>) {
+                result = result[ruleList.first().rule]?.toString()
             } else {
                 for (sourceRule in ruleList) {
                     putRule(sourceRule.putMap)
@@ -298,6 +327,7 @@ class AnalyzeRule(
                     val rule = sourceRule.rule
                     if (rule.isNotBlank() || sourceRule.replaceRegex.isEmpty()) {
                         result = when (sourceRule.mode) {
+                            Mode.WebJs -> getWebJsResult(rule, result)
                             Mode.Js -> evalJS(rule, result)
                             Mode.Json -> getAnalyzeByJSonPath(result).getString(rule)
                             Mode.XPath -> getAnalyzeByXPath(result).getString(rule)
@@ -354,6 +384,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                     )
 
+                    Mode.WebJs -> GSON.fromJsonObject<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getObject(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -387,6 +421,10 @@ class AnalyzeRule(
                         rule.splitNotBlank("&&")
                     )
 
+                    Mode.WebJs -> GSON.fromJsonArray<Map<String, Any?>>(
+                        getWebJsResult(rule, result)
+                    ).getOrNull()
+
                     Mode.Js -> evalJS(rule, result)
                     Mode.Json -> getAnalyzeByJSonPath(result).getList(rule)
                     Mode.XPath -> getAnalyzeByXPath(result).getElements(rule)
@@ -515,6 +553,18 @@ class AnalyzeRule(
             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) {
             tmp = ruleStr.substring(start).trim { it <= ' ' }
             if (tmp.isNotEmpty()) {
@@ -738,7 +788,7 @@ class AnalyzeRule(
     }
 
     enum class Mode {
-        XPath, Json, Default, Js, Regex
+        XPath, Json, Default, Js, Regex, WebJs
     }
 
     /**
@@ -826,6 +876,10 @@ class AnalyzeRule(
      * js实现跨域访问,不能删
      */
     override fun ajax(url: Any): String? {
+        return ajax(url, null)
+    }
+
+    override fun ajax(url: Any, callTimeout: Long?): String? {
         val urlStr = if (url is List<*>) {
             url.firstOrNull().toString()
         } else {
@@ -835,6 +889,7 @@ class AnalyzeRule(
             urlStr,
             source = source,
             ruleData = book,
+            callTimeout = callTimeout,
             coroutineContext = coroutineContext
         )
         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.data.entities.BaseSource
 import io.legado.app.data.entities.Book
-import io.legado.app.exception.NoStackTraceException
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.CacheManager
 import io.legado.app.help.ConcurrentRateLimiter
 import io.legado.app.help.JsExtensions
@@ -239,7 +239,11 @@ class AnalyzeUrl(
             }
             urlOption?.let { option ->
                 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 ->
                     headerMap[entry.key.toString()] = entry.value.toString()
@@ -278,6 +282,8 @@ class AnalyzeUrl(
                     analyzeFields(it)
                 }
             }
+
+            RequestMethod.HEAD -> Unit
         }
     }
 
@@ -485,6 +491,11 @@ class AnalyzeUrl(
                             }
                         }
 
+                        RequestMethod.HEAD -> {
+                            get(urlNoQuery, encodedQuery)
+                            head()
+                        }
+
                         else -> get(urlNoQuery, encodedQuery)
                     }
                 }.let {
@@ -559,6 +570,11 @@ class AnalyzeUrl(
                         }
                     }
 
+                    RequestMethod.HEAD -> {
+                        get(urlNoQuery, encodedQuery)
+                        head()
+                    }
+
                     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.help.book.ContentProcessor
 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.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
@@ -164,7 +163,7 @@ object BookChapterList {
             list.getOrElse(book.simulatedTotalChapterNum() - 1) { list.last() }
                 .getDisplayTitle(replaceRules, book.getUseReplaceRule())
         coroutineContext.ensureActive()
-        getWordCount(list, book)
+        preserveChapterMetadata(list, book)
         return list
     }
 
@@ -270,17 +269,18 @@ object BookChapterList {
         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)
         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) {
-                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.help.book.BookHelp
 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.config.AppConfig
 import io.legado.app.model.Debug
@@ -137,6 +138,26 @@ object BookContent {
                 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()
         val titleRule = contentRule.title //先正文再章节名称
         if (!titleRule.isNullOrBlank()) {
@@ -225,13 +246,6 @@ object BookContent {
                 content = content.replace(placeholder, originalContent)
             }
         }
-        //获取副文(歌词等)
-        contentRule.subContent?.takeIf { it.isNotBlank() }?.let {
-            val subContent = analyzeRule.getString(it, unescape = false)
-            if (subContent.isNotBlank()) {
-                chapter.putVariable("lyric", subContent)
-            }
-        }
         //获取下一页链接
         if (getNextPageUrl) {
             val nextUrlRule = contentRule.nextContentUrl

+ 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.NotificationId
 import io.legado.app.constant.Status
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -74,6 +75,9 @@ class AudioPlayService : BaseService(),
 
         @JvmStatic
         var timeMinute: Int = 0
+            set(value) {
+                field = PlaybackTimer.normalize(value)
+            }
 
         var url: String = ""
             private set
@@ -388,12 +392,7 @@ class AudioPlayService : BaseService(),
     }
 
     private fun addTimer() {
-        if (timeMinute == 180) {
-            timeMinute = 0
-        } else {
-            timeMinute += 10
-            if (timeMinute > 180) timeMinute = 180
-        }
+        timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
     }
 
@@ -404,14 +403,15 @@ class AudioPlayService : BaseService(),
         postEvent(EventBus.AUDIO_DS, timeMinute)
         upAudioPlayNotification()
         dsJob?.cancel()
+        dsJob = null
+        if (timeMinute == PlaybackTimer.MIN_MINUTES) return
         dsJob = lifecycleScope.launch {
             while (isActive) {
                 delay(60000)
+                if (timeMinute == PlaybackTimer.MIN_MINUTES) break
                 if (!pause) {
-                    if (timeMinute >= 0) {
-                        timeMinute--
-                    }
-                    if (timeMinute == 0) {
+                    timeMinute--
+                    if (timeMinute == PlaybackTimer.MIN_MINUTES) {
                         AudioPlay.stop()
                         postEvent(EventBus.AUDIO_DS, timeMinute)
                         break
@@ -572,7 +572,8 @@ class AudioPlayService : BaseService(),
     private fun createNotification(): NotificationCompat.Builder {
         val nTitle: String = when {
             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)
         } + ": ${AudioPlay.book?.name}"
 
@@ -684,4 +685,4 @@ class AudioPlayService : BaseService(),
         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.PreferKey
 import io.legado.app.constant.Status
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
@@ -93,6 +94,18 @@ abstract class BaseReadAloudService : BaseService(),
 
         @JvmStatic
         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
 
         fun isPlay(): Boolean {
@@ -176,8 +189,8 @@ abstract class BaseReadAloudService : BaseService(),
         initPhoneStateListener()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         setTimer(ReadConfig.ttsTimer)
-        if (ReadConfig.ttsTimer > 0) {
-            toastOnUi("朗读定时 ${ReadConfig.ttsTimer} 分钟")
+        if (timeMinute > 0) {
+            toastOnUi("朗读定时 $timeMinute 分钟")
         }
         execute {
             ImageLoader
@@ -211,6 +224,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     override fun onDestroy() {
+        ReadBook.upReadTime()
         super.onDestroy()
         prepareReadAloudGeneration++
         prepareReadAloudJob?.cancel()
@@ -221,6 +235,8 @@ abstract class BaseReadAloudService : BaseService(),
         isRun = false
         pause = true
         sessionStore.stop()
+        currentChapterIndex = -1
+        currentProgress = 0
         abandonFocus()
         unregisterReceiver(broadcastReceiver)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
@@ -229,6 +245,10 @@ abstract class BaseReadAloudService : BaseService(),
         mediaSessionCompat.release()
         ReadBook.uploadProgress()
         unregisterPhoneStateListener(phoneStateListener)
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         upNotificationJob?.invokeOnCompletion {
             notificationManager.cancel(NotificationId.ReadAloudService)
         }
@@ -245,6 +265,7 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.pause -> pauseReadAloud()
             IntentAction.resume -> resumeReadAloud()
             IntentAction.upTtsSpeechRate -> upSpeechRate(true)
+            IntentAction.syncReadAloudLayout -> syncTextChapterLayout()
             IntentAction.prevParagraph -> prevP()
             IntentAction.nextParagraph -> nextP()
             IntentAction.prev -> prevChapter()
@@ -328,6 +349,7 @@ abstract class BaseReadAloudService : BaseService(),
             nowSpeak = preparedNowSpeak
             readAloudNumber = preparedReadAloudNumber
             paragraphStartPos = preparedParagraphStartPos
+            updateReadAloudProgressSnapshot(preparedReadAloudNumber + 1)
             if (moveToLast) toLast = false
             preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
             launch(Main) {
@@ -374,12 +396,16 @@ abstract class BaseReadAloudService : BaseService(),
         upReadAloudNotification()
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
 
     abstract fun playStop()
 
     @CallSuper
     open fun pauseReadAloud(abandonFocus: Boolean = true) {
+        ReadBook.upReadTime()
         if (useWakeLock) {
             wakeLock.release()
             wifiLock?.release()
@@ -394,6 +420,10 @@ abstract class BaseReadAloudService : BaseService(),
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         ReadBook.uploadProgress()
         doDs()
+        if (!ReadBook.isUiActive) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
     }
 
     @SuppressLint("WakelockTimeout")
@@ -410,11 +440,15 @@ abstract class BaseReadAloudService : BaseService(),
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
+        if (!ReadBook.isAutoSaveSessionRunning) {
+            ReadBook.startReadSession()
+        }
     }
 
     abstract fun upSpeechRate(reset: Boolean = false)
 
     fun upTtsProgress(progress: Int) {
+        ReadBook.upReadTime()
         val chapterPosition = progress.coerceAtLeast(0)
         if (hasSpeechPlaybackQueue) {
             playbackQueue.cursorAt(chapterPosition)?.let(::publishPlaybackInfo)
@@ -430,10 +464,52 @@ abstract class BaseReadAloudService : BaseService(),
                 )
             )
         }
+        updateReadAloudProgressSnapshot(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() {
+        ReadBook.upReadTime()
         if (hasSpeechPlaybackQueue) {
             val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
             playbackQueue.previous(current)?.let { previous ->
@@ -548,12 +624,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun addTimer() {
-        if (timeMinute == 180) {
-            timeMinute = 0
-        } else {
-            timeMinute += 10
-            if (timeMinute > 180) timeMinute = 180
-        }
+        timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
     }
 
@@ -566,14 +637,15 @@ abstract class BaseReadAloudService : BaseService(),
         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
         upReadAloudNotification()
         dsJob?.cancel()
+        dsJob = null
+        if (timeMinute == PlaybackTimer.MIN_MINUTES) return
         dsJob = lifecycleScope.launch {
             while (isActive) {
                 delay(60000)
+                if (timeMinute == PlaybackTimer.MIN_MINUTES) break
                 if (!pause) {
-                    if (timeMinute >= 0) {
-                        timeMinute--
-                    }
-                    if (timeMinute == 0) {
+                    timeMinute--
+                    if (timeMinute == PlaybackTimer.MIN_MINUTES) {
                         ReadAloud.stop(this@BaseReadAloudService)
                         sessionStore.updateTimer(timeMinute)
                         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
@@ -868,6 +940,7 @@ abstract class BaseReadAloudService : BaseService(),
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
     open fun prevChapter() {
+        ReadBook.upReadTime()
         toLast = false
         resumeReadAloudInternal()
         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() {
         playIndexJob?.cancel()
-        val textChapter = textChapter ?: return
+        if (textChapter == null) return
         playIndexJob = lifecycleScope.launch {
-            upTtsProgress(readAloudNumber + 1)
             if (exoPlayer.duration <= 0) {
+                upTtsProgress(readAloudNumber + 1)
                 return@launch
             }
             val speakTextLength = contentList[nowSpeak].length
@@ -789,13 +789,12 @@ class HttpReadAloudService : BaseReadAloudService(),
             }
             val sleep = exoPlayer.duration / speakTextLength
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
+            upTtsProgress(readAloudNumber + start.toInt() + 1)
             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)
             }

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

@@ -40,6 +40,7 @@ class TTSReadAloudService : BaseReadAloudService() {
     private val ttsUtteranceListener = TTSUtteranceListener()
     private var speakJob: Coroutine<*>? = null
     private var utteranceStartPos = 0
+    private var utteranceStartReadAloudNumber = 0
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
     private var activeEngine = ""
     private var activeVoiceName = ""
@@ -324,6 +325,7 @@ class TTSReadAloudService : BaseReadAloudService() {
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
+            utteranceStartReadAloudNumber = readAloudNumber
             textChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph()
@@ -353,17 +355,13 @@ class TTSReadAloudService : BaseReadAloudService() {
             super.onRangeStart(utteranceId, start, end, frame)
             if (!isCurrentUtterance(utteranceId)) return
             paragraphStartPos = utteranceStartPos + start
+            readAloudNumber = currentRangePosition(utteranceStartReadAloudNumber, start)
+            updateReadAloudProgressSnapshot(readAloudNumber + 1)
             val msg =
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
             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 {
-                readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
+                readAloudNumber = nextParagraphPosition(
+                    currentPosition = readAloudNumber,
+                    paragraphLength = contentList[nowSpeak].length,
+                    paragraphStartPosition = paragraphStartPos,
+                )
                 paragraphStartPos = 0
                 nowSpeak++
                 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(
                         onClick = { onSwitchBranch(-1) },
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
-                        contentDescription = "Previous branch",
+                        contentDescription = stringResource(R.string.previous),
                         modifier = Modifier.size(28.dp)
                     )
                     AppText(
@@ -208,7 +208,7 @@ fun AiGeneratedMessageContent(
                     SmallPlainButton(
                         onClick = { onSwitchBranch(1) },
                         icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
-                        contentDescription = "Next branch",
+                        contentDescription = stringResource(R.string.next),
                         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
     val pressProgress: Float get() = pressProgressAnimation.value
     val offset: Offset get() = positionAnimation.value
+    val dragOffset: Offset get() = positionAnimation.value - startPosition
 
     @Language("AGSL")
     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.adapter.ItemViewHolder
 import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BookSource
 import io.legado.app.databinding.DialogCustomGroupBinding
@@ -173,12 +172,12 @@ class ImportBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_r
 
             R.id.menu_keep_original_name -> {
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepName, item.isChecked)
+                AppConfig.importKeepName = item.isChecked
             }
 
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
+                AppConfig.importKeepGroup = item.isChecked
             }
 
             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.adapter.ItemViewHolder
 import io.legado.app.base.adapter.RecyclerAdapter
-import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.RssSource
 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_keep_original_name -> {
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepName, item.isChecked)
+                AppConfig.importKeepName = item.isChecked
             }
 
             R.id.menu_keep_group -> {
                 item.isChecked = !item.isChecked
-                putPrefBoolean(PreferKey.importKeepGroup, item.isChecked)
+                AppConfig.importKeepGroup = item.isChecked
             }
 
             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.BookSource
 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.removeType
 import io.legado.app.help.config.AppConfig
@@ -396,10 +397,10 @@ class AudioPlayActivity :
                 AudioPlay.setTimer(0)
             }
             binding.settingSlider.apply {
-                valueFrom = 0f
-                valueTo = 180f
+                valueFrom = PlaybackTimer.MIN_MINUTES.toFloat()
+                valueTo = PlaybackTimer.MAX_MINUTES.toFloat()
                 stepSize = 1f
-                value = AudioPlayService.timeMinute.toFloat()
+                value = PlaybackTimer.normalize(AudioPlayService.timeMinute).toFloat()
 
                 addOnChangeListener { _, newValue, fromUser ->
                     if (fromUser) AudioPlay.setTimer(newValue.toInt())
@@ -798,4 +799,3 @@ private fun Int.copy(alpha: Float): Int {
     val alpha = (alpha * 255).toInt()
     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.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
@@ -42,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 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.TextCard
 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.AppLinearProgressIndicator
 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.TopBarActionButton
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.toastOnUi
 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
 fun BookCacheManageRouteScreen(
     onBackClick: () -> Unit,
@@ -95,24 +108,86 @@ private fun BookCacheManageScreen(
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     var pendingDeleteBook by remember { mutableStateOf<BookCacheBookItem?>(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 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 bookshelfSectionEmptyText = stringResource(R.string.cache_empty_bookshelf)
     val notBookshelfSectionTitle = stringResource(R.string.cache_section_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(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
-            GlassMediumFlexibleTopAppBar(
+            DynamicTopAppBar(
                 title = stringResource(R.string.cache_management),
                 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(
                         onClick = { onIntent(BookCacheManageIntent.Refresh) },
                         imageVector = Icons.Default.Refresh,
@@ -166,20 +241,7 @@ private fun BookCacheManageScreen(
                 cacheSection(
                     title = bookshelfSectionTitle,
                     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,
                     chaptersByBookUrl = state.chaptersByBookUrl,
                     onToggleExpanded = { bookUrl ->
@@ -189,6 +251,23 @@ private fun BookCacheManageScreen(
                     onDeleteBook = { pendingDeleteBook = it },
                     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.data.dao.BookChapterDao
 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.BookChapter
+import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.model.BookChapterCacheInfo
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ClearBookCacheUseCase
@@ -25,12 +27,12 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
-import kotlinx.coroutines.isActive
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.util.concurrent.ConcurrentHashMap
@@ -40,6 +42,7 @@ data class BookCacheManageUiState(
     val isLoading: Boolean = true,
     val shelfBooks: List<BookCacheBookItem> = emptyList(),
     val notShelfBooks: List<BookCacheBookItem> = emptyList(),
+    val groups: List<BookGroup> = emptyList(),
     val expandedBookUrls: Set<String> = emptySet(),
     val chaptersByBookUrl: Map<String, List<BookCacheChapterItem>> = emptyMap(),
     val downloadSummary: String = "",
@@ -59,6 +62,7 @@ data class BookCacheBookItem(
     val pausedCount: Int,
     val errorCount: Int,
     val isNotShelf: Boolean,
+    val group: Long,
 ) {
     val progress: Float get() = if (totalCount == 0) 0f else cachedCount.toFloat() / totalCount
     val hasActiveDownload: Boolean get() = waitingCount > 0 || downloadingCount > 0
@@ -107,6 +111,7 @@ class BookCacheManageViewModel(
     application: Application,
     private val bookDao: BookDao,
     private val bookChapterDao: BookChapterDao,
+    private val bookGroupDao: BookGroupDao,
     private val cacheBookChaptersUseCase: CacheBookChaptersUseCase,
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
 ) : BaseViewModel(application) {
@@ -163,6 +168,15 @@ class BookCacheManageViewModel(
                 reloadAll(books = books, forceDatabase = false)
             }
         }
+        viewModelScope.launch {
+            bookGroupDao.flowSelect().collect { groups ->
+                _uiState.update { state ->
+                    state.copy(
+                        groups = groups.filter { it.groupId > 0L }
+                    )
+                }
+            }
+        }
         viewModelScope.launch {
             CacheBook.cacheSuccessFlow.collect { chapter ->
                 scheduleDownloadStatusRefresh(chapter.bookUrl)
@@ -381,6 +395,7 @@ class BookCacheManageViewModel(
             pausedCount = pausedCount,
             errorCount = errorIndices.size,
             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.asStateFlow
 import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 
 class ChangeChapterSourceViewModel(
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
@@ -85,7 +87,9 @@ class ChangeChapterSourceViewModel(
         }
         if (searchJob?.isActive != true) {
             viewModelScope.launch {
-                val dbBooks = getDbSearchBooks()
+                val dbBooks = withContext(Dispatchers.IO) {
+                    getDbSearchBooks()
+                }
                 if (dbBooks.isNotEmpty()) {
                     searchResults.clear()
                     searchResults.addAll(dbBooks)
@@ -330,15 +334,19 @@ class ChangeChapterSourceViewModel(
     }
 
     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 {
             try {
-                val (toc, _) = getChapterContentUseCase.getToc(book)
+                val (toc, _) = withContext(Dispatchers.IO) {
+                    getChapterContentUseCase.getToc(book)
+                }
                 val currentTocIndex = getChapterContentUseCase.getDurChapterIndex(
                     chapterIndex = chapterIndex,
                     chapterTitle = chapterTitle,
@@ -401,7 +411,7 @@ class ChangeChapterSourceViewModel(
                         currentTocIndex = currentTocIndex,
                     )
                 }
-            } catch (e: Exception) {
+            } catch (_: Exception) {
                 _uiState.update {
                     it.copy(
                         showToc = false,
@@ -426,8 +436,9 @@ class ChangeChapterSourceViewModel(
                 val searchBook = selectedSearchBook.toBook()
                 val toc = _uiState.value.tocItems
                 val nextChapterUrl = toc.getOrNull(chapter.index + 1)?.url
-                val content =
+                val content = withContext(Dispatchers.IO) {
                     getChapterContentUseCase.getContent(searchBook, chapter, nextChapterUrl)
+                }
                 _uiState.update { it.copy(isLoadingToc = false) }
                 _effects.tryEmit(ChangeChapterSourceEffect.ReplaceContent(content))
                 _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.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.data.entities.SearchBook
+import io.legado.app.R
 import io.legado.app.domain.model.BookShelfState
 import io.legado.app.ui.config.coverConfig.CoverConfig
 import io.legado.app.ui.main.bookCoverSharedElementKey
@@ -264,20 +266,20 @@ fun ExploreShowScreen(
                                 )
                             },
                             imageVector = Icons.AutoMirrored.Outlined.FormatListBulleted,
-                            contentDescription = "列数设置"
+                            contentDescription = stringResource(R.string.a11y_grid_columns)
                         )
                     }
 
                     TopBarActionButton(
                         onClick = { viewModel.onIntent(ExploreShowIntent.ShowSheet(ExploreShowSheet.KindSelect)) },
                         imageVector = Icons.Outlined.FilterAlt,
-                        contentDescription = "分类"
+                        contentDescription = stringResource(R.string.select_or_search_category)
                     )
 
                     TopBarActionButton(
                         onClick = { viewModel.onIntent(ExploreShowIntent.ToggleLayout) },
                         imageVector = if (!isGridMode) Icons.AutoMirrored.Outlined.FormatListBulleted else Icons.Default.GridView,
-                        contentDescription = "切换布局"
+                        contentDescription = stringResource(R.string.a11y_switch_layout)
                     )
                 },
                 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,
     onSearchQueryChange: (String) -> Unit,
     onSelectFolder: () -> Unit,
+    onSelectBookFiles: () -> Unit,
     onScanFolder: () -> Unit,
     onImportFileName: () -> Unit,
     onSortChange: (Int) -> Unit,
@@ -192,7 +193,7 @@ private fun ImportBookContent(
                 )
             )
         ),
-        onAddClick = null
+        onAddClick = onSelectBookFiles
     ) { paddingValues ->
         AppPullToRefresh(
             modifier = Modifier
@@ -252,6 +253,11 @@ fun ImportBookScreen(
     ) { uri ->
         viewModel.dispatch(ImportBookIntent.FolderPicked(uri, pickerTarget))
     }
+    val selectBookFiles = rememberLauncherForActivityResult(
+        contract = ActivityResultContracts.OpenMultipleDocuments()
+    ) { uris ->
+        viewModel.dispatch(ImportBookIntent.BookFilesPicked(uris))
+    }
     val handleBack = {
         when {
             uiState.selectedIds.isNotEmpty() -> viewModel.clearSelection()
@@ -359,6 +365,21 @@ fun ImportBookScreen(
         onSearchToggle = { viewModel.dispatch(ImportBookIntent.SearchToggle(it)) },
         onSearchQueryChange = { viewModel.dispatch(ImportBookIntent.SearchQueryChange(it)) },
         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) },
         onImportFileName = {
             fileNameJs = ImportBookConfig.bookImportFileName.orEmpty()
@@ -434,7 +455,7 @@ private fun ImportPathNavigationBar(
             SmallTonalButton(
                 onClick = onNavigateBack,
                 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))
                 SmallPlainButton(
                     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
 
-import io.legado.app.ui.config.otherConfig.OtherConfig
 import android.app.Application
 import android.net.Uri
 import android.provider.DocumentsContract
 import android.provider.DocumentsContract.getTreeDocumentId
+import androidx.compose.runtime.Immutable
 import androidx.core.net.toUri
 import androidx.documentfile.provider.DocumentFile
-import androidx.compose.runtime.Immutable
 import androidx.lifecycle.viewModelScope
 import io.legado.app.base.BaseViewModel
 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.model.localBook.LocalBook
 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.ListUiState
 import io.legado.app.utils.AlphanumComparator
@@ -35,19 +35,17 @@ import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.consumeAsFlow
 import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.stateIn
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
@@ -79,6 +77,7 @@ sealed interface ImportBookIntent {
     data object Initialize : ImportBookIntent
     data object SelectFolderClick : 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 SearchQueryChange(val query: String) : 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.BookFilesPicked -> importPickedBookFiles(intent.uris)
             is ImportBookIntent.SearchToggle -> setSearchMode(intent.enabled)
             is ImportBookIntent.SearchQueryChange -> setSearchKey(intent.query)
             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) {
         if (item.isDir || item.isOnBookShelf) return
         execute {

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

@@ -170,7 +170,8 @@ fun RemoteBookScreen(
             {
                 MediumPlainButton(
                     onClick = { showSheet = RemoteBookSheet.ServerConfig(null) },
-                    icon = Icons.Default.Add
+                    icon = Icons.Default.Add,
+                    contentDescription = stringResource(R.string.add)
                 )
             }
         } else {
@@ -264,7 +265,7 @@ fun RemoteBookScreen(
             TopBarActionButton(
                 onClick = { showSheet = RemoteBookSheet.Servers },
                 imageVector = Icons.Default.Storage,
-                contentDescription = "服务器"
+                contentDescription = stringResource(R.string.a11y_server_list)
             )
         },
         dropDownMenuContent = { dismiss ->
@@ -438,14 +439,14 @@ private fun ServerItem(
                         SmallPlainButton(
                             onClick = it,
                             icon = Icons.Default.Edit,
-                            contentDescription = "Edit"
+                            contentDescription = stringResource(R.string.edit)
                         )
                     }
                     onDelete?.let {
                         SmallPlainButton(
                             onClick = it,
                             icon = Icons.Default.Delete,
-                            contentDescription = "Delete"
+                            contentDescription = stringResource(R.string.delete)
                         )
                     }
                 }
@@ -519,7 +520,9 @@ private fun ServerConfigSheetContent(
                 IconButton(onClick = { passwordVisible = !passwordVisible }) {
                     Icon(
                         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(
                 onClick = onNavigateBack,
                 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(
     val book: BookInfoBookUi? = null,
     val hasChapters: Boolean = false,
+    val tocLoadFailed: Boolean = false,
     val webFiles: List<BookInfoWebFile> = emptyList(),
     val highlightedTags: List<HighlightedTag> = emptyList(),
     val kindLabels: List<String> = emptyList(),
@@ -62,6 +63,7 @@ data class BookInfoBookUi(
     val latestChapterTitle: String?,
     val totalChapterNum: Int,
     val durChapterIndex: Int,
+    val durChapterPos: Int,
     val remark: String?,
     val displayIntro: String?,
 )
@@ -70,6 +72,7 @@ data class BookInfoBookUi(
 data class BookInfoSourceUi(
     val sourceUrl: String,
     val hasLogin: Boolean,
+    val hasCustomButton: Boolean,
 )
 
 @Stable
@@ -272,9 +275,11 @@ sealed interface BookInfoCallbackAction {
     data class ShareText(val chooserTitle: String, val text: String) : BookInfoCallbackAction
     data class CopyText(val text: String) : BookInfoCallbackAction
     data object ClearCache : BookInfoCallbackAction
+    data object None : BookInfoCallbackAction
 }
 
 enum class BookInfoMenuAction {
+    CustomButton,
     Edit,
     Share,
     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.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleEventObserver
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 import io.legado.app.help.book.isAudio
 import io.legado.app.help.book.isImage
 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.ui.book.audio.AudioPlayActivity
 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.toc.TocActivityResult
 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.login.SourceLoginActivity
 import io.legado.app.ui.widget.dialog.VariableDialog
@@ -272,6 +271,8 @@ private fun runSourceCallback(
             BookInfoCallbackAction.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
         }
     }
-    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(
             state = state,
             onIntent = onIntent,
@@ -170,6 +185,13 @@ fun BookInfoScreen(
             sharedTransitionScope = sharedTransitionScope,
             animatedVisibilityScope = animatedVisibilityScope,
             sharedCoverKey = sharedCoverKey,
+            backdropStyle = backdropStyle,
+            usesDefaultCover = usesDefaultCover,
+            onNetworkCoverLoadError = { failedCoverPath ->
+                if (failedCoverPath == state.book?.coverPath) {
+                    usesDefaultCover = true
+                }
+            },
         )
     }
 }
@@ -185,6 +207,9 @@ private fun BookInfoScreenContent(
     sharedTransitionScope: SharedTransitionScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     sharedCoverKey: String?,
+    backdropStyle: BookInfoBackdropStyle?,
+    usesDefaultCover: Boolean,
+    onNetworkCoverLoadError: (String?) -> Unit,
 ) {
     val isMiuix = ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)
     val scrollBehavior = if (isMiuix) {
@@ -224,9 +249,13 @@ private fun BookInfoScreenContent(
         if (book == null) {
             Box(modifier = Modifier.fillMaxSize())
         } else {
+            val resolvedBackdropStyle = requireNotNull(backdropStyle)
             Box(modifier = Modifier.fillMaxSize()) {
                 BookInfoBackdrop(
                     book = book,
+                    style = resolvedBackdropStyle,
+                    usesDefaultCover = usesDefaultCover,
+                    onNetworkCoverLoadError = onNetworkCoverLoadError,
                 )
                 AppPullToRefresh(
                     modifier = Modifier.fillMaxSize(),
@@ -254,6 +283,11 @@ private fun BookInfoScreenContent(
                                 onAuthorClick = { onIntent(BookInfoIntent.AuthorClick(it)) },
                                 onBookNameClick = { onIntent(BookInfoIntent.BookNameClick(it)) },
                                 onOriginClick = { onIntent(BookInfoIntent.OriginClick) },
+                                onNetworkCoverLoadError = {
+                                    onNetworkCoverLoadError(book.coverPath)
+                                },
+                                usesDefaultCover = usesDefaultCover,
+                                applySeedOverlay = resolvedBackdropStyle.applySeedOverlay,
                                 sharedTransitionScope = sharedTransitionScope,
                                 animatedVisibilityScope = animatedVisibilityScope,
                                 sharedCoverKey = sharedCoverKey,
@@ -308,7 +342,7 @@ private fun BookInfoScreenContent(
                                 }
                                 BookInfoSummary(
                                     book = book,
-                                    hasChapters = state.hasChapters,
+                                    tocLoadFailed = state.tocLoadFailed,
                                     onRemarkClick = { onIntent(BookInfoIntent.RemarkClick) },
                                 )
                             }
@@ -507,9 +541,12 @@ private fun BookInfoTransparentTopAppBar(
 @Composable
 private fun rememberBookInfoColorTheme(
     book: BookInfoBookUi?,
+    enabled: Boolean,
+    usesDefaultCover: Boolean,
 ): ThemeOverrideState? {
     if (
         book == null ||
+        !enabled ||
         !ThemeConfig.bookInfoFollowCoverColor
     ) {
         return null
@@ -517,18 +554,17 @@ private fun rememberBookInfoColorTheme(
 
     val imageLoader = koinInject<ImageLoader>()
     val isNight = LegadoTheme.isDark
-    val useDefaultCover = usesDefaultBookCover(book.coverPath)
     val defaultCoverPaths =
         if (isNight) CoverConfig.defaultCoverDark else CoverConfig.defaultCover
     val coverPath = remember(
         book.name,
         book.author,
         book.coverPath,
-        useDefaultCover,
+        usesDefaultCover,
         isNight,
         defaultCoverPaths,
     ) {
-        if (useDefaultCover) {
+        if (usesDefaultCover) {
             BookCoverModel.getRandomDefaultPath(
                 seed = book.name,
                 isNight = isNight,
@@ -537,8 +573,8 @@ private fun rememberBookInfoColorTheme(
             book.coverPath
         }
     } ?: 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) {
         listOf(coverPath, sourceOrigin, loadOnlyWifi)
     }
@@ -555,6 +591,18 @@ private fun rememberBookInfoColorTheme(
     return rememberThemeOverride(seedColor)
 }
 
+private fun resolveBookInfoBackdropStyle(
+    book: BookInfoBookUi,
+    usesDefaultCover: Boolean,
+): BookInfoBackdropStyle {
+    val backgroundMode = if (usesDefaultCover) {
+        ThemeConfig.bookInfoDefaultCoverBackground
+    } else {
+        ThemeConfig.bookInfoNetworkCoverBackground
+    }
+    return resolveBookInfoBackdropStyle(backgroundMode)
+}
+
 @Composable
 private fun BookInfoTopBarActions(
     state: BookInfoUiState,
@@ -593,23 +641,22 @@ private fun BookInfoTopBarActions(
 @Composable
 private fun BookInfoBackdrop(
     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(
         book.name,
         book.author,
         book.coverPath,
         book.origin,
+        usesDefaultCover,
     ) {
         BookInfoBackdropState(
             name = book.name,
             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(
@@ -622,7 +669,7 @@ private fun BookInfoBackdrop(
             .fillMaxSize()
             .clearAndSetSemantics { }
     ) {
-        if (showBackgroundCover) {
+        if (style.showCover) {
             Crossfade(
                 targetState = backdropState,
                 animationSpec = tween(800),
@@ -638,7 +685,7 @@ private fun BookInfoBackdrop(
                         .fillMaxWidth()
                         .height(480.dp)
                         .then(
-                            if (blurBackground) {
+                            if (style.blurCover) {
                                 Modifier.blur(24.dp)
                             } else {
                                 Modifier
@@ -646,17 +693,20 @@ private fun BookInfoBackdrop(
                         ),
                     contentScale = ContentScale.Crop,
                     showLoadingPlaceholder = false,
+                    onError = { onNetworkCoverLoadError(currentBook.coverPath) },
                     requestBuilder = {
                         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(
             modifier = Modifier
                 .fillMaxSize()
@@ -664,8 +714,16 @@ private fun BookInfoBackdrop(
                     Brush.verticalGradient(
                         colorStops = arrayOf(
                             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.80f to LegadoTheme.colorScheme.surface,
                             1f to LegadoTheme.colorScheme.surface,
@@ -692,6 +750,12 @@ private fun BookInfoOverflowMenu(
 ) {
     val book = state.book
     RoundDropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
+        if (state.bookSource?.hasCustomButton == true) {
+            RoundDropdownMenuItem(
+                text = stringResource(R.string.custom_button),
+                onClick = { onMenuAction(BookInfoMenuAction.CustomButton) }
+            )
+        }
         if (state.inBookshelf) {
             RoundDropdownMenuItem(
                 text = stringResource(R.string.edit),
@@ -785,6 +849,9 @@ private fun BookInfoHeader(
     onAuthorClick: (Boolean) -> Unit,
     onBookNameClick: (Boolean) -> Unit,
     onOriginClick: () -> Unit,
+    onNetworkCoverLoadError: () -> Unit,
+    usesDefaultCover: Boolean,
+    applySeedOverlay: Boolean,
     sharedTransitionScope: SharedTransitionScope?,
     animatedVisibilityScope: AnimatedVisibilityScope?,
     sharedCoverKey: String?,
@@ -797,8 +864,12 @@ private fun BookInfoHeader(
                 Brush.verticalGradient(
                     colors = listOf(
                         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,
                     )
                 )
@@ -827,8 +898,9 @@ private fun BookInfoHeader(
                     CoilBookCover(
                         name = book.name,
                         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
                             .width(112.dp)
                             .aspectRatio(5f / 7f),
@@ -1052,7 +1124,7 @@ private fun BookInfoActionCard(
 @Composable
 private fun BookInfoSummary(
     book: BookInfoBookUi,
-    hasChapters: Boolean,
+    tocLoadFailed: Boolean,
     onRemarkClick: () -> Unit,
 ) {
     Column(
@@ -1088,17 +1160,25 @@ private fun BookInfoSummary(
                 color = LegadoTheme.colorScheme.secondary
             )
             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,
                 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))
         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 = {
             MediumPlainButton(
                 onClick = { showAddGroup = true },
-                icon = Icons.Default.Add
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.group_add)
             )
         },
         endAction = {
             MediumPlainButton(
                 onClick = { onConfirm(selectedGroupId) },
-                icon = Icons.Default.Check
+                icon = Icons.Default.Check,
+                contentDescription = stringResource(R.string.confirm)
             )
         }
     ) {
@@ -190,7 +192,8 @@ fun GroupSelectSheet(
                         trailingAction = {
                             SmallPlainButton(
                                 onClick = { editingGroup = group },
-                                icon = Icons.Default.Edit
+                                icon = Icons.Default.Edit,
+                                contentDescription = stringResource(R.string.edit)
                             )
                         },
                         containerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -239,7 +242,10 @@ fun ChangeCoverSheet(
         endAction = {
             MediumPlainButton(
                 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.help.book.BookHelp
 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.isLocal
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.help.book.isSameNameAuthor
 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.upKind
-import io.legado.app.help.book.parseHighlightedTags
 import io.legado.app.help.book.updateTo
 import io.legado.app.help.config.LocalConfig
 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.fromJsonArray
 import io.legado.app.utils.postEvent
-import io.legado.app.utils.splitNotBlank
 import io.legado.app.utils.toastOnUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
@@ -70,14 +70,14 @@ import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.callbackFlow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.combine
@@ -140,6 +140,7 @@ class BookInfoViewModel(
             observeReadRecordIfNeeded(value)
         }
     private var currentChapterList: List<BookChapter> = emptyList()
+    private var tocLoadFailed = false
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
     private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
     private var currentCharacters: List<BookInfoCharacterUi> = emptyList()
@@ -179,7 +180,8 @@ class BookInfoViewModel(
         origin: String? = null,
         coverPath: String? = null
     ) {
-        if (currentBook?.bookUrl == bookUrl) return
+        val current = currentBook
+        if (current != null) return
         _uiState.value = BookInfoUiState() // 立即重置 UI 状态
         currentBook = if (!name.isNullOrBlank() && !author.isNullOrBlank()) {
             Book(
@@ -846,6 +848,7 @@ class BookInfoViewModel(
     private fun upBook(book: Book, source: BookSource?) {
         currentBook = book
         currentChapterList = emptyList()
+        tocLoadFailed = false
         currentWebFiles = emptyList()
         currentRelatedBooks = emptyList()
         currentCharacters = emptyList()
@@ -907,7 +910,7 @@ class BookInfoViewModel(
             val groupNames = appDb.bookGroupDao.getGroupNames(book.group).joinToString(",")
             val normalizedGroupNames = groupNames.ifBlank { null }
             appDb.bookDao.update(book)
-            val finalKinds = book.kind?.splitNotBlank(",", "\n").orEmpty().toList()
+            val finalKinds = book.getDisplayTagList()
             val enabledRules = appDb.highlightTagRuleDao.getEnabled()
             val (highlighted, regular) = parseHighlightedTags(finalKinds, enabledRules)
             HighlightMeta(highlighted, regular, normalizedGroupNames, hasCustomGroup)
@@ -933,6 +936,7 @@ class BookInfoViewModel(
         scope: CoroutineScope = viewModelScope,
         showLoading: Boolean = true,
     ) {
+        tocLoadFailed = false
         syncUiState(isTocLoading = showLoading)
         if (book.isLocal) {
             execute(scope) {
@@ -948,6 +952,7 @@ class BookInfoViewModel(
                 syncUiState(isTocLoading = false)
             }.onError {
                 currentChapterList = emptyList()
+                tocLoadFailed = true
                 syncUiState(isTocLoading = false)
             }
         } else {
@@ -974,6 +979,7 @@ class BookInfoViewModel(
                     syncUiState(isTocLoading = false)
                 }.onError {
                     currentChapterList = emptyList()
+                    tocLoadFailed = true
                     syncUiState(isTocLoading = false)
                     AppLog.put("获取目录失败\n${it.localizedMessage}", it)
                 }
@@ -1139,6 +1145,14 @@ class BookInfoViewModel(
     private fun handleMenuAction(action: BookInfoMenuAction) {
         val book = currentBook ?: return
         when (action) {
+            BookInfoMenuAction.CustomButton -> emitEffect(
+                BookInfoEffect.RunSourceCallback(
+                    event = SourceCallBack.CLICK_CUSTOM_BUTTON,
+                    source = bookSource,
+                    book = book.uiCopy(),
+                    action = BookInfoCallbackAction.None,
+                )
+            )
             BookInfoMenuAction.Edit -> openEdit()
             BookInfoMenuAction.Share -> {
                 val bookJson = GSON.toJson(book)
@@ -1379,6 +1393,7 @@ class BookInfoViewModel(
             it.copy(
                 book = currentBook?.toBookInfoBookUi(),
                 hasChapters = currentChapterList.isNotEmpty(),
+                tocLoadFailed = tocLoadFailed,
                 webFiles = currentWebFiles,
                 relatedBooks = currentRelatedBooks.toImmutableList(),
                 characters = currentCharacters.toImmutableList(),
@@ -1664,6 +1679,7 @@ class BookInfoViewModel(
             latestChapterTitle = latestChapterTitle,
             totalChapterNum = totalChapterNum,
             durChapterIndex = durChapterIndex,
+            durChapterPos = durChapterPos,
             remark = remark,
             displayIntro = getDisplayIntro(),
         )
@@ -1673,6 +1689,7 @@ class BookInfoViewModel(
         return BookInfoSourceUi(
             sourceUrl = bookSourceUrl,
             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.layout.Arrangement
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
 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.rememberLazyListState
 import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.input.TextFieldLineLimits
 import androidx.compose.foundation.text.input.rememberTextFieldState
 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.ExposedDropdownMenuBox
 import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Surface
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
@@ -53,18 +52,20 @@ import androidx.compose.ui.zIndex
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
 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.fadingEdge
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.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.menuItem.RoundDropdownMenu
 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.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.GlassTopAppBarDefaults
 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(
                         onClick = { selectCover.launch() },
-                        icon = Icons.Default.FolderOpen
+                        icon = Icons.Default.FolderOpen,
+                        contentDescription = stringResource(R.string.select_folder)
                     )
                     MediumOutlinedButton(
                         onClick = { viewModel.resetCover() },
-                        icon = Icons.Default.Replay
+                        icon = Icons.Default.Replay,
+                        contentDescription = stringResource(R.string.cover_reset)
                     )
                 }
                 Spacer(modifier = Modifier.height(4.dp))
@@ -196,17 +200,11 @@ fun BookInfoEditContent(
             onCheckedChange = { viewModel.onFixedTypeChange(it) }
         )
         Spacer(modifier = Modifier.height(16.dp))
-        val bookInfoInputColor = ThemeConfig.bookInfoInputColor
-        val inputBackgroundColor = if (bookInfoInputColor != 0) {
-            Color(bookInfoInputColor)
-        } else {
-            Color.Unspecified
-        }
         AppTextField(
             value = uiState.name,
             onValueChange = { viewModel.onNameChange(it) },
             label = stringResource(R.string.book_name),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -214,7 +212,7 @@ fun BookInfoEditContent(
             value = uiState.author,
             onValueChange = { viewModel.onAuthorChange(it) },
             label = stringResource(R.string.author),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -222,7 +220,16 @@ fun BookInfoEditContent(
             value = uiState.coverUrl ?: "",
             onValueChange = { viewModel.onCoverUrlChange(it) },
             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()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -230,14 +237,14 @@ fun BookInfoEditContent(
             kindList = uiState.kindList,
             onKindListChange = { viewModel.onKindListChange(it) },
             onReset = { viewModel.resetKinds() },
-            backgroundColor = inputBackgroundColor
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
         )
         Spacer(modifier = Modifier.height(8.dp))
         AppTextField(
             value = uiState.intro ?: "",
             onValueChange = { viewModel.onIntroChange(it) },
             label = stringResource(R.string.book_intro),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
         )
         Spacer(modifier = Modifier.height(8.dp))
@@ -245,7 +252,7 @@ fun BookInfoEditContent(
             value = uiState.remark ?: "",
             onValueChange = { viewModel.onRemarkChange(it) },
             label = stringResource(R.string.book_remark),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             modifier = Modifier.fillMaxWidth()
         )
     }
@@ -269,13 +276,6 @@ fun BookTypeDropdown(
         textFieldState.setTextAndPlaceCursorAtEnd(selectedTypeLabel)
     }
 
-    val bookInfoInputColor = ThemeConfig.bookInfoInputColor
-    val inputBackgroundColor = if (bookInfoInputColor != 0) {
-        Color(bookInfoInputColor)
-    } else {
-        Color.Unspecified
-    }
-
     ExposedDropdownMenuBox(
         modifier = Modifier.padding(horizontal = 8.dp),
         expanded = expanded,
@@ -286,7 +286,7 @@ fun BookTypeDropdown(
             readOnly = true,
             lineLimits = TextFieldLineLimits.SingleLine,
             label = stringResource(R.string.book_type_label),
-            backgroundColor = inputBackgroundColor,
+            backgroundColor = LegadoTheme.colorScheme.surfaceInput,
             trailingIcon = {
                 ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
             },
@@ -328,65 +328,88 @@ fun KindEditor(
     val hapticFeedback = LocalHapticFeedback.current
 
     val listState = rememberLazyListState()
-    val reorderableState = rememberReorderableLazyListState(listState) { from, to ->
+    fun moveKind(from: Int, to: Int) {
         val mutable = kindList.toMutableList()
-        mutable.add(to.index, mutable.removeAt(from.index))
+        mutable.add(to, mutable.removeAt(from))
         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
         ) {
-            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) {
         val isAdding = editingIndex == -1
         AppAlertDialog(
@@ -443,12 +466,13 @@ private fun KindChip(
     onClick: () -> Unit,
     modifier: Modifier = Modifier
 ) {
-    Surface(
-        shape = RoundedCornerShape(8.dp),
-        color = LegadoTheme.colorScheme.surfaceContainer,
+    NormalCard(
+        cornerRadius = 8.dp,
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
         contentColor = LegadoTheme.colorScheme.onSurface,
-        shadowElevation = if (isDragging) 8.dp else 0.dp,
+        elevation = if (isDragging) 2.dp else 0.dp,
         modifier = modifier
+            .padding(2.dp)
             .zIndex(if (isDragging) 1f else 0f)
             .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.externalFiles
 import io.legado.app.utils.inputStream
+import io.legado.app.utils.splitNotBlank
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -38,6 +39,7 @@ data class BookInfoEditUiState(
     val coverUrl: String? = null,
     val intro: String? = null,
     val remark: String? = null,
+    val sourceKindList: List<String> = emptyList(),
     val kindList: List<String> = emptyList(),
     val originalKindList: List<String> = emptyList(),
     val selectedType: BookInfoEditType = BookInfoEditType.TEXT,
@@ -59,16 +61,17 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                     it.isAudio -> BookInfoEditType.AUDIO
                     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(
                     name = it.name,
                     author = it.author,
                     coverUrl = it.getDisplayCover(),
                     intro = it.getDisplayIntro(),
                     remark = it.remark,
-                    kindList = kinds,
-                    originalKindList = kinds,
+                    sourceKindList = sourceKinds,
+                    kindList = customTags,
+                    originalKindList = customTags,
                     selectedType = selectedType,
                     fixedType = it.config.fixedType,
                     book = it
@@ -102,7 +105,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
     }
 
     fun onKindListChange(kindList: List<String>) {
-        _uiState.value = _uiState.value.copy(kindList = kindList)
+        _uiState.value = _uiState.value.copy(kindList = kindList.distinct())
     }
 
     fun onBookTypeChange(bookType: BookInfoEditType) {
@@ -136,7 +139,7 @@ class BookInfoEditViewModel(application: Application) : BaseViewModel(applicatio
                 book.config.fixedType = currentState.fixedType
                 book.customCoverUrl = if (currentState.coverUrl == book.coverUrl) null else currentState.coverUrl
                 book.customIntro = if (currentState.intro == book.intro) null else currentState.intro
-                book.kind = currentState.kindList.joinToString(",")
+                book.customTag = currentState.kindList.joinToString(",").ifBlank { null }
                 BookHelp.updateCacheFolder(oldBook, book)
 
                 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.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 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.RoundDropdownMenuItem
 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.OptionSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
@@ -295,7 +297,7 @@ private fun BookshelfManageScreen(
         }
         val bookUrl = pendingExportBookUrl ?: return@rememberLauncherForActivityResult
         val book = booksByUrl[bookUrl] ?: return@rememberLauncherForActivityResult
-        if (state.exportConfig.enableCustomExport) {
+        if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = dirPath
             customExportBook = book
             customExportAllChapter = false
@@ -336,7 +338,7 @@ private fun BookshelfManageScreen(
         val path = ACache.get().getAsString(exportBookPathKey)
         if (path.isNullOrEmpty() || !FileDoc.fromDir(path).checkWrite()) {
             selectExportFolder(book.bookUrl)
-        } else if (state.exportConfig.enableCustomExport) {
+        } else if (state.exportConfig.isCustomEpubExportEnabled) {
             customExportPath = path
             customExportBook = book
             customExportAllChapter = false
@@ -478,7 +480,7 @@ private fun BookshelfManageScreen(
                 TopBarActionButton(
                     onClick = { showGroupMenu = true },
                     imageVector = AppIcons.Filter,
-                    contentDescription = null
+                    contentDescription = stringResource(R.string.a11y_group_filter)
                 )
                 RoundDropdownMenu(
                     expanded = showGroupMenu,
@@ -656,7 +658,7 @@ private fun BookshelfManageScreen(
             ),
             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) {
                     viewModel.getCacheCount(book.bookUrl) ?: 0
                 }
@@ -687,6 +689,15 @@ private fun BookshelfManageScreen(
                     GlassCard(
                         modifier = Modifier
                             .fillMaxWidth()
+                            .reorderAccessibility(
+                                index = index,
+                                itemCount = filteredBooks.size,
+                                enabled = canReorderBooks,
+                            ) { from, to ->
+                                viewModel.dispatch(
+                                    BookshelfManageScreenIntent.MoveBookOrder(from, to)
+                                )
+                            }
                             .then(
                                 if (canReorderBooks) {
                                     Modifier.longPressDraggableHandle()
@@ -801,12 +812,12 @@ private fun BookshelfManageScreen(
                                             }
                                         },
                                         icon = if (isDownloading) Icons.Default.Stop else Icons.Default.Download,
-                                        contentDescription = "download"
+                                        contentDescription = stringResource(R.string.action_download)
                                     )
                                     SmallTonalButton(
                                         onClick = { exportBook(book) },
                                         icon = Icons.Default.Upload,
-                                        contentDescription = "upload"
+                                        contentDescription = stringResource(R.string.upload_to_remote)
                                     )
                                     SmallTonalButton(
                                         onClick = {
@@ -815,12 +826,12 @@ private fun BookshelfManageScreen(
                                             showGroupSelectSheet = true
                                         },
                                         icon = Icons.Default.Bookmarks,
-                                        contentDescription = "group"
+                                        contentDescription = stringResource(R.string.group)
                                     )
                                     SmallTonalButton(
                                         onClick = { moreMenuBookUrl = book.bookUrl },
                                         icon = Icons.Default.MoreVert,
-                                        contentDescription = "more"
+                                        contentDescription = stringResource(R.string.more_menu)
                                     )
                                 }
                             }
@@ -1349,6 +1360,13 @@ private fun BookSourcePickerSheet(
                     ReorderableSelectionItem(
                         state = reorderableState,
                         key = source.bookSourceUrl,
+                        reorderIndex = selectedSources.indexOf(source),
+                        reorderItemCount = selectedSources.size,
+                        onMoveItem = { from, to ->
+                            selectedSources = selectedSources.toMutableList().apply {
+                                move(from, to)
+                            }
+                        },
                         title = source.bookSourceName,
                         subtitle = source.bookSourceGroup,
                         isSelected = true,
@@ -1526,7 +1544,8 @@ private fun BatchChangePreviewRow(
             ) {
                 SmallTonalButton(
                     onClick = { onManualSearch(item.oldBook) },
-                    icon = Icons.Default.Search
+                    icon = Icons.Default.Search,
+                    contentDescription = stringResource(R.string.search)
                 )
                 SmallTonalButton(
                     onClick = { onSkip(item.oldBook.bookUrl) },
@@ -1634,7 +1653,7 @@ private fun OtherSourceOptionsSheet(
                         SmallTonalButton(
                             onClick = { onOpenBook(candidate.book) },
                             icon = Icons.Default.Info,
-                            contentDescription = null,
+                            contentDescription = stringResource(R.string.details),
                         )
                     },
                     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 bookExportFileName: String? = null,
     val episodeExportFileName: String = ""
-)
+) {
+    val isCustomEpubExportEnabled: Boolean
+        get() = enableCustomExport && exportType == 1
+}
 
 data class BookshelfManageScreenUiState(
     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.toc.TocActivityResult
 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.readMangaConfig.ReadMangaConfig
 import io.legado.app.ui.login.SourceLoginActivity
@@ -140,6 +141,8 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
 
     private var justInitData: Boolean = false
+    private var isExitActionRunning = false
+    private var isRestoredFromSavedState = false
     private var syncDialog: AlertDialog? = null
     private val handler by lazy { buildMainHandler() }
     private val eyeProtectionScheduler by lazy {
@@ -199,6 +202,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
     }
 
     override fun onCreate(savedInstanceState: Bundle?) {
+        isRestoredFromSavedState = savedInstanceState != null
         setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback())
         val transform = MaterialContainerTransform().apply {
@@ -235,15 +239,61 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             GSON.fromJsonObject<MangaFooterConfig>(ReadMangaConfig.mangaFooterConfig).getOrNull()
                 ?: 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() {
         observeEvent<MangaFooterConfig>(EventBus.UP_MANGA_CONFIG) {
             mMangaFooterConfig = it
@@ -315,11 +365,11 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
             }
 
             override fun nextChapter() {
-                ReadManga.moveToNextChapter()
+                ReadManga.moveToNextChapter(toFirst = true)
             }
 
             override fun prevChapter() {
-                ReadManga.moveToPrevChapter()
+                ReadManga.moveToPrevChapter(toFirst = true)
             }
         }
 
@@ -395,6 +445,7 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
 
     private fun upInfoBar(page: Any?) {
         if (page !is MangaPage) {
+            binding.webtoonFrame.updateAccessibilityState(getString(R.string.loading))
             return
         }
         val chapterIndex = page.chapterIndex
@@ -402,6 +453,13 @@ class ReadMangaActivity : VMBaseActivity<ActivityMangaBinding, ReadMangaViewMode
         val chapterPos = page.index
         val imageCount = page.imageCount
         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 {
             mLabelBuilder.clear()
             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 bookUrl = book?.bookUrl
         Coroutine.async {
             book?.delete()
         }.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 {
-            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 {
             success?.invoke()
         }.onError {
@@ -409,4 +432,13 @@ class ReadMangaViewModel(
             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 var mRetry: Button? = null
 
-    //private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
+    private val minHeight = context.resources.displayMetrics.heightPixels * 2 / 3
 
     fun initComponent(
         loading: ProgressBar,
@@ -111,7 +111,7 @@ open class MangaVH<VB : ViewBinding>(val binding: VB, private val context: Conte
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                                 height = ViewGroup.LayoutParams.WRAP_CONTENT
                             }
-                            itemView.minimumHeight = 0
+                            itemView.minimumHeight = minHeight
                         } else {
                             mImage.updateLayoutParams<FrameLayout.LayoutParams> {
                                 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.MotionEvent
 import android.view.ScaleGestureDetector
+import android.view.View
 import android.widget.FrameLayout
+import androidx.core.view.ViewCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
 import androidx.recyclerview.widget.RecyclerView
+import io.legado.app.R
 import io.legado.app.ui.config.readMangaConfig.ReadMangaConfig
 import io.legado.app.utils.findCenterViewPosition
 
@@ -22,6 +26,22 @@ class WebtoonFrame : FrameLayout {
         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
 
@@ -125,6 +145,29 @@ class WebtoonFrame : FrameLayout {
         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) {
         when (action) {
             -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 =
             (layoutManager as LinearLayoutManager).findLastVisibleItemPosition()
         firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
+        if (dx != 0 || dy != 0) {
+            notifyCenterPositionChanged(dx, dy)
+        }
     }
 
     override fun onScrollStateChanged(state: Int) {
@@ -83,13 +86,17 @@ class WebtoonRecyclerView @JvmOverloads constructor(
         offsetInWindow: IntArray?,
         type: Int
     ): 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()
         if (position != NO_POSITION && position != mLastCenterViewPosition) {
             mLastCenterViewPosition = 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 {

+ 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 surfaceColor = when (background.type) {
-        0 -> background.value.toColorOrNull()
-        else -> ReadBookConfig.bgMeanColor.takeIf { it != 0 }?.let(::Color)
-    }
     return rememberReadThemeOverride(
         seedColor = sourceColor,
-        backgroundColor = surfaceColor,
+        backgroundColor = null,
         containerColor = null,
         deriveDarkFromColor = false,
         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 textColorNight: String = "#CCCCCC",
     val textColorEInk: String = "#000000",
+    val textFont: String = "",
+    val titleFont: String = "",
     // Page anim
     val pageAnim: Int = 0,
     val pageAnimEInk: Int = 4,
@@ -311,8 +313,11 @@ data class ReadMenuConfig(
     val readMenuBorderWidth: Int = 0,
     val readMenuBorderColor: Int = 0,
     val readMenuBorderColorNight: Int = 0,
+    val readMenuTextColor: Int = 0,
+    val readMenuTextColorNight: Int = 0,
     val readMenuBlurAlpha: Int = 60,
     val readMenuBlurColor: Int = 0,
+    val readMenuBlurColorNight: Int = 0,
     val readMenuPaletteStyle: String = "",
     val readMenuBlurRadius: Int = 24,
     val readMenuLensRadius: Float = 24f,
@@ -324,6 +329,7 @@ data class ReadMenuConfig(
     val readMenuTopBarBlurStyle: Int = ReadMenuBlurStyle.Progressive,
     val readMenuBottomBarBlurStyle: Int = ReadMenuBlurStyle.Solid,
     val readMenuIconStyle: Int = 0,
+    val titleBarIconStyle: Int = 0,
     val readMenuIconShowText: Boolean = true,
     val readSliderMode: String = "0",
     val titleBarCustomIcons: ImmutableMap<String, String> = persistentMapOf(),
@@ -476,6 +482,7 @@ sealed interface ReadBookIntent {
     data object OpenBookInfo : ReadBookIntent
     data object OpenChapterList : ReadBookIntent
     data object OpenChapterUrl : ReadBookIntent
+    data class SourceCustomButton(val longClick: Boolean) : ReadBookIntent
     data object ToggleReadUrlInBrowser : ReadBookIntent
 
     // Content edit
@@ -684,7 +691,6 @@ sealed interface ReadBookIntent {
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
-    data class SaveReadAloudTtsTimer(val value: Int) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data class SetSpeechAnalysisMode(val value: String) : ReadBookIntent
@@ -791,6 +797,13 @@ sealed interface ReadBookEffect {
         val html: String? = null,
     ) : ReadBookEffect
 
+    data class RunSourceCustomButton(
+        val event: String,
+        val source: BookSource,
+        val book: Book,
+        val chapter: BookChapter?,
+    ) : ReadBookEffect
+
     // Menu actions that need Activity
     data object MenuChangeSource : ReadBookEffect
     data object MenuBookChangeSource : ReadBookEffect
@@ -810,7 +823,6 @@ sealed interface ReadBookEffect {
     data class UpTextSelectAble(val enabled: Boolean) : ReadBookEffect
 
     // TTS
-    data class UpTtsAloudSpan(val chapterStart: Int) : ReadBookEffect
 
     // Dialogs (Activity-driven)
     data object ShowConfirmSkipToChapter : ReadBookEffect
@@ -892,7 +904,6 @@ sealed interface ReadBookSheet {
     data object ParagraphIntervalConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet
     data object PageKeyConfig : ReadBookSheet
-    data object TextSelectMenuFilterConfig : ReadBookSheet
     data object InfoConfig : ReadBookSheet
     data class Dict(val word: String) : ReadBookSheet
     data class Bookmark(
@@ -928,6 +939,7 @@ sealed interface ConfigUpdateAction {
     data object UpdateBackgroundAlpha : ConfigUpdateAction
     data object UpdatePageSlopSquare : ConfigUpdateAction
     data object ReloadContent : ConfigUpdateAction
+    data object RelayoutContent : ConfigUpdateAction
     data object UpdateContent : ConfigUpdateAction
     data object UpdateChapterStyle : ConfigUpdateAction
     data object InvalidateTextPage : ConfigUpdateAction
@@ -1001,6 +1013,9 @@ sealed interface ConfigUpdate {
     data class TitleColor(val color: Int) : ConfigUpdate {
         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 {
         override val actions = setOf(ConfigUpdateAction.UpdateChapterStyle, ConfigUpdateAction.ReloadContent)
     }
@@ -1048,9 +1063,15 @@ sealed interface ConfigUpdate {
     data class TipHeaderColor(val color: Int) : ConfigUpdate {
         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 {
         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 {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
@@ -1100,6 +1121,12 @@ sealed interface ConfigUpdate {
     data class MenuContainerColorNight(val color: Int) : ConfigUpdate {
         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 {
         override val actions = setOf(ConfigUpdateAction.UpdateSystemUi)
     }
@@ -1243,6 +1270,10 @@ sealed interface ConfigUpdate {
     data class MenuIconStyle(val value: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
+
+    data class TitleBarIconStyle(val value: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class MenuIconItemsPerRow(val value: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
@@ -1285,6 +1316,9 @@ sealed interface ConfigUpdate {
     data class MenuBlurColor(val color: Int) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
+    data class MenuBlurColorNight(val color: Int) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class MenuPaletteStyle(val value: String) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
@@ -1415,9 +1449,6 @@ sealed interface ConfigUpdate {
     data class ShowSelectMenuIcon(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
-    data class TextSelectMenuFilter(val value: String) : ConfigUpdate {
-        override val actions = emptySet<ConfigUpdateAction>()
-    }
     data class ShowReadTitleAddition(val value: Boolean) : ConfigUpdate {
         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
 
 import android.annotation.SuppressLint
+import android.app.SearchManager
+import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.os.Build
 import android.view.Gravity
@@ -12,6 +14,7 @@ import android.view.WindowInsets
 import android.view.WindowManager
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.content.ContextCompat
+import androidx.core.net.toUri
 import androidx.core.view.HapticFeedbackConstantsCompat
 import androidx.core.view.WindowInsetsCompat
 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.fromJsonObject
 import io.legado.app.utils.invisible
+import io.legado.app.utils.isAbsUrl
 import io.legado.app.utils.longToastOnUi
 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.setOnApplyWindowInsetsListenerCompat
+import io.legado.app.utils.share
 import io.legado.app.utils.sysBattery
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
@@ -62,7 +69,13 @@ import io.legado.app.utils.toastOnUi
 import io.legado.app.utils.visible
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 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.
@@ -74,8 +87,7 @@ class ReadBookController(
 ) : ReadBookRouteHost,
     ReadBookInputHandler,
     ReadView.CallBack,
-    ContentTextView.CallBack,
-    TextActionMenu.CallBack {
+    ContentTextView.CallBack {
 
     var refs: ReadBookViewRefs? = null
 
@@ -91,6 +103,15 @@ class ReadBookController(
         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
     var onScreenOffTimerStart: (() -> Unit)? = null
     var onStartContentLoadFinish: (() -> Unit)? = null
@@ -106,12 +127,16 @@ class ReadBookController(
     private val networkChangedListener by lazy { NetworkChangedListener(activity) }
     private val handler by lazy { buildMainHandler() }
     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 var screenTimeOut: Long = 0
@@ -134,7 +159,7 @@ class ReadBookController(
     fun clearTts() {
         tts?.clearTts()
         tts = null
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
         popupAction.dismiss()
         refs?.readView?.onDestroy()
         networkChangedListener.unRegister()
@@ -174,6 +199,7 @@ class ReadBookController(
         }
         newRefs.readView.upTime()
         newRefs.readView.upBattery(activity.sysBattery)
+        refreshActionMenuItems()
     }
 
     fun onMenuVisibilityChanged(visible: Boolean) {
@@ -285,22 +311,30 @@ class ReadBookController(
 
     override fun showTextActionMenu() {
         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() {
@@ -415,7 +449,7 @@ class ReadBookController(
     override fun onCancelSelect() {
         refs?.cursorLeft?.invisible()
         refs?.cursorRight?.invisible()
-        textActionMenu.dismiss()
+        dismissTextActionMenu()
     }
 
     override fun onLongScreenshotTouchEvent(event: MotionEvent): Boolean =
@@ -501,11 +535,18 @@ class ReadBookController(
 
     override fun onTouch(v: View?, event: MotionEvent?): Boolean {
         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
         }
-        when (event.action) {
-            MotionEvent.ACTION_DOWN -> textActionMenu.dismiss()
+        when (action) {
+            MotionEvent.ACTION_DOWN -> dismissTextActionMenu()
             MotionEvent.ACTION_MOVE -> {
                 when (v.id) {
                     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()
-                showTextActionMenu()
+                if (r.readView.isTextSelected) {
+                    showTextActionMenu()
+                } else {
+                    dismissTextActionMenu()
+                }
             }
         }
         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) {
             R.id.menu_aloud -> {
                 viewModel.onIntent(
@@ -612,11 +661,140 @@ class ReadBookController(
         return false
     }
 
-    override fun onMenuActionFinally() {
-        textActionMenu.dismiss()
+    fun onMenuActionFinally() {
+        dismissTextActionMenu()
         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 ───────────────────────────────────────────────
 
     /**
@@ -638,6 +816,7 @@ class ReadBookController(
                         ConfigUpdateAction.UpdateBackgroundAlpha -> r.readView.upBgAlpha()
                         ConfigUpdateAction.UpdatePageSlopSquare -> r.readView.upPageSlopSquare()
                         ConfigUpdateAction.ReloadContent -> if (viewModel.isInitFinish) ReadBook.loadContent(resetPageOffset = false)
+                        ConfigUpdateAction.RelayoutContent -> if (viewModel.isInitFinish) ReadBook.relayoutContent()
                         ConfigUpdateAction.UpdateContent -> r.readView.upContent(resetPageOffset = false)
                         ConfigUpdateAction.UpdateChapterStyle -> ChapterProvider.upStyle()
                         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 -> {
                 ReadBook.curTextChapter = null
                 refs?.readView?.upContent()
@@ -736,6 +901,7 @@ class ReadBookController(
             }
 
             is ReadBookEffect.ContentLoadFinish -> {
+                viewModel.readAloudProgress.value?.let(::updateReadAloudProgress)
                 onStartContentLoadFinish?.invoke()
             }
 
@@ -855,6 +1021,7 @@ class ReadBookController(
             is ReadBookEffect.OpenSearchActivity,
             is ReadBookEffect.ShowLogin,
             is ReadBookEffect.OpenWebView,
+            is ReadBookEffect.RunSourceCustomButton,
             is ReadBookEffect.MenuSettingReplace,
             is ReadBookEffect.TextActionReplace,
             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 ──
 
     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 androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
@@ -26,6 +27,7 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.key
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
@@ -41,13 +43,16 @@ import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.hazeSource
 import io.legado.app.R
 import io.legado.app.constant.AppLog
+import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentHelp
 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.read.page.ContentTextView
 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.sheet.TextSelectMenuConfigSheet
 import io.legado.app.ui.book.searchContent.SearchContentResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
@@ -87,6 +92,8 @@ interface ReadBookRouteHost :
 
     fun closeReadBook()
 
+    fun previewBrightness(value: Int)
+
     fun upSystemUiVisibility(
         isInMultiWindow: Boolean,
         toolBarHide: Boolean,
@@ -121,6 +128,7 @@ fun ReadBookRouteScreen(
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
+    val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
     val lifecycleOwner = LocalLifecycleOwner.current
     val effectsReady = remember(viewModel) { CompletableDeferred<Unit>() }
@@ -137,6 +145,14 @@ fun ReadBookRouteScreen(
         controller.onMenuVisibilityChanged(state.menuVisible)
     }
 
+    LaunchedEffect(viewModel, controller, lifecycleOwner) {
+        lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
+            viewModel.readAloudProgress.collect { chapterStart ->
+                chapterStart?.let(controller::updateReadAloudProgress)
+            }
+        }
+    }
+
     // ── ActivityResult Launchers ──────────────────────────────────────
 
     val tocLauncher = rememberLauncherForActivityResult(TocActivityResult()) { result ->
@@ -303,8 +319,7 @@ fun ReadBookRouteScreen(
                             is ReadBookEffect.ShowLogin -> {
                                 context.startActivity(
                                     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 -> {
                                 onOpenSearch(effect.word, effect.bookUrl)
                             }
@@ -450,13 +477,26 @@ fun ReadBookRouteScreen(
                     SearchContentResult.resetReplayCache()
                     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()
             }
@@ -465,6 +505,8 @@ fun ReadBookRouteScreen(
 
     // ── View layer + Compose UI ───────────────────────────────────────
 
+    var showSelectMenuConfigSheet by rememberSaveable { mutableStateOf(false) }
+
     Box(Modifier.fillMaxSize()) {
         key(controller) {
             ReadBookViewLayer(
@@ -485,6 +527,7 @@ fun ReadBookRouteScreen(
                 state = state,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
+                onBrightnessPreview = host::previewBrightness,
                 backdrop = menuBackdrop,
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
             )
@@ -514,8 +557,47 @@ fun ReadBookRouteScreen(
             }
             ReadBookScreen(
                 state = state,
+                preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 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.lifecycle.compose.collectAsStateWithLifecycle
 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.AiTextCleanSheet
 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.ReadAloudPlayerSheet
 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.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
@@ -70,8 +68,10 @@ import org.koin.compose.koinInject
 @Composable
 fun ReadBookScreen(
     state: ReadBookUiState,
+    preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
+    onOpenTextSelectMenuConfig: () -> Unit,
 ) {
     BackHandler {
         when {
@@ -199,24 +199,15 @@ fun ReadBookScreen(
         onDismissRequest = dismissSheet,
         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)
     FontSelectSheet(
         show = state.activeSheet is ReadBookSheet.FontSelect,
         title = stringResource(R.string.select_font),
         folderState = fontSelectFolderState,
-        selectedFontPath = ReadBookConfig.textFont,
+        selectedFontPath = state.styleConfig.textFont,
         onDismissRequest = dismissSheet,
         onSelectFont = { onIntent(ReadBookIntent.SelectFont(it.uri.toString())) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectSystemTypeface(it)) },
@@ -227,7 +218,7 @@ fun ReadBookScreen(
         show = state.activeSheet is ReadBookSheet.TitleFontSelect,
         title = stringResource(R.string.read_config_title_settings),
         folderState = fontSelectFolderState,
-        selectedFontPath = ReadBookConfig.titleFont,
+        selectedFontPath = state.styleConfig.titleFont,
         onDismissRequest = dismissSheet,
         onSelectFont = { onIntent(ReadBookIntent.SelectTitleFont(it.uri.toString())) },
         onSelectSystemTypeface = { onIntent(ReadBookIntent.SelectTitleSystemTypeface(it)) },
@@ -296,10 +287,7 @@ fun ReadBookScreen(
             onIntent(ReadBookIntent.DismissSheet)
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.PageKeyConfig))
         },
-        onOpenTextSelectMenuFilterConfig = {
-            onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.TextSelectMenuFilterConfig))
-        },
+        onOpenTextSelectMenuConfig = onOpenTextSelectMenuConfig,
     )
     ReadAloudConfigSheet(
         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 -> {
             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.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
+import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 
@@ -46,13 +48,13 @@ fun ReadBookSearchBar(
                 onClick = {
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(-1))
                 },
-                tooltipText = "上一个结果",
+                tooltipText = stringResource(R.string.a11y_previous_result),
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
             ) {
                 Icon(
                     Icons.AutoMirrored.Filled.ArrowBack,
-                    contentDescription = "上一个结果",
+                    contentDescription = stringResource(R.string.a11y_previous_result),
                     modifier = Modifier.size(20.dp),
                 )
             }
@@ -71,13 +73,13 @@ fun ReadBookSearchBar(
                 onClick = {
                     onIntent(ReadBookIntent.NavigateSearchResultByOffset(1))
                 },
-                tooltipText = "下一个结果",
+                tooltipText = stringResource(R.string.a11y_next_result),
                 containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
                 contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
             ) {
                 Icon(
                     Icons.AutoMirrored.Filled.ArrowForward,
-                    contentDescription = "下一个结果",
+                    contentDescription = stringResource(R.string.a11y_next_result),
                     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.TextProcessAnchor
 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.readaloud.ReadAloudVoice
 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.ReadBook
 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.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
@@ -189,6 +191,14 @@ class ReadBookViewModel(
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     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) {
         _effects.subscriptionCount.first { it > 0 }
         _effects.emit(effect)
@@ -592,6 +602,7 @@ class ReadBookViewModel(
                 }
             }
             is ReadBookIntent.OpenChapterUrl -> openChapterUrl()
+            is ReadBookIntent.SourceCustomButton -> runSourceCustomButton(intent.longClick)
             is ReadBookIntent.ToggleReadUrlInBrowser -> toggleReadUrlInBrowser()
             is ReadBookIntent.OpenContentEdit -> openContentEdit()
             is ReadBookIntent.LoadContentEdit -> loadContentEdit()
@@ -717,8 +728,12 @@ class ReadBookViewModel(
 
             is ReadBookIntent.MenuEnableReplace -> {
                 ReadBook.book?.let {
-                    it.setUseReplaceRule(!it.getUseReplaceRule())
+                    val enabled = !it.getUseReplaceRule()
+                    it.setUseReplaceRule(enabled)
                     ReadBook.saveRead()
+                    _uiState.update { state ->
+                        state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
+                    }
                     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 -> {
                 if (_uiState.value.defaultReadAloudInterface ==
                     ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
@@ -1162,10 +1177,6 @@ class ReadBookViewModel(
             )
             is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
             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 -> {
                 viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
                 _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
@@ -1451,9 +1462,8 @@ class ReadBookViewModel(
 
     private fun handleOnResume() {
         // Read time tracking
-        ReadBook.readStartTime = System.currentTimeMillis()
-        ReadBook.initReadTime()
-        ReadBook.startAutoSaveSession()
+        ReadBook.isUiActive = true
+        ReadBook.startReadSession()
 
         // Web book progress sync
         ReadBook.webBookProgress?.let {
@@ -1478,9 +1488,12 @@ class ReadBookViewModel(
         _effects.tryEmit(ReadBookEffect.StopAutoPage)
 
         // Read time tracking
+        ReadBook.isUiActive = false
         ReadBook.saveRead()
-        ReadBook.stopAutoSaveSession()
-        ReadBook.commitReadSession()
+        if (!BaseReadAloudService.isPlay()) {
+            ReadBook.stopAutoSaveSession()
+            ReadBook.commitReadSession()
+        }
         ReadBook.cancelPreDownloadTask()
 
         // View-layer
@@ -1972,6 +1985,7 @@ class ReadBookViewModel(
                         9 -> ConfigUpdateAction.InvalidateTextPage
                         10 -> ConfigUpdateAction.UpdateLayout
                         11 -> ConfigUpdateAction.SubmitRenderTask
+                        12 -> ConfigUpdateAction.RelayoutContent
                         else -> null
                     }
                 }.toSet()
@@ -2001,11 +2015,24 @@ class ReadBookViewModel(
                     (status == ReadAloudSessionStatus.Idle ||
                         status == ReadAloudSessionStatus.Paused)
                 ) {
+                    _readAloudProgress.value = null
                     _effects.tryEmit(ReadBookEffect.UpAloudState)
                 }
+                if (previousStatus != ReadAloudSessionStatus.Paused &&
+                    status == ReadAloudSessionStatus.Paused
+                ) {
+                    _effects.tryEmit(
+                        ReadBookEffect.ShowToast(context.getString(R.string.read_aloud_pause))
+                    )
+                }
                 previousStatus = status
             }
         }
+        viewModelScope.launch {
+            eventFlow<Int>(EventBus.READ_ALOUD_DS).collect { minute ->
+                _uiState.update { it.copy(readAloudTtsTimer = minute.coerceAtLeast(0)) }
+            }
+        }
         viewModelScope.launch {
             @Suppress("UNCHECKED_CAST")
             eventFlow<List<SearchResult>>(EventBus.SEARCH_RESULT).collect { results ->
@@ -2034,11 +2061,17 @@ class ReadBookViewModel(
         }
         viewModelScope.launch {
             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() {
         viewModelScope.launch {
             var previous: ReadPreferences? = null
@@ -2107,8 +2140,9 @@ class ReadBookViewModel(
     }
 
     private fun setReadAloudTtsTimer(value: Int) {
-        val timer = value.coerceIn(0, 180)
+        val timer = PlaybackTimer.normalize(value)
         ReadAloud.setTimer(context, timer)
+        viewModelScope.launch { readAloudSettingsRepository.setTtsTimer(timer) }
         _uiState.update { it.copy(readAloudTtsTimer = timer) }
     }
 
@@ -2155,6 +2189,8 @@ class ReadBookViewModel(
             textColor = dur.getTextColor(),
             textColorNight = dur.getTextColorNight(),
             textColorEInk = dur.getTextColorEInk(),
+            textFont = config.textFont,
+            titleFont = config.titleFont,
             pageAnim = actualConfig.getPageAnim(),
             pageAnimEInk = actualConfig.getPageAnimEInk(),
             shareLayout = config.shareLayout,
@@ -2203,8 +2239,11 @@ class ReadBookViewModel(
                 readMenuBorderWidth = ReadBookConfig.readMenuBorderWidth,
                 readMenuBorderColor = ReadBookConfig.readMenuBorderColor,
                 readMenuBorderColorNight = ReadBookConfig.readMenuBorderColorNight,
+                readMenuTextColor = ReadBookConfig.readMenuTextColor,
+                readMenuTextColorNight = ReadBookConfig.readMenuTextColorNight,
                 readMenuBlurAlpha = ReadBookConfig.readMenuBlurAlpha,
                 readMenuBlurColor = ReadBookConfig.readMenuBlurColor,
+                readMenuBlurColorNight = ReadBookConfig.readMenuBlurColorNight,
                 readMenuPaletteStyle = ReadBookConfig.readMenuPaletteStyle,
                 readMenuBlurRadius = ReadBookConfig.readMenuBlurRadius,
                 readMenuLensRadius = ReadBookConfig.readMenuLensRadius,
@@ -2216,6 +2255,7 @@ class ReadBookViewModel(
                 readMenuTopBarBlurStyle = ReadBookConfig.readMenuTopBarBlurStyle,
                 readMenuBottomBarBlurStyle = ReadBookConfig.readMenuBottomBarBlurStyle,
                 readMenuIconStyle = ReadBookConfig.readMenuIconStyle,
+                titleBarIconStyle = ReadBookConfig.titleBarIconStyle,
                 readMenuIconShowText = ReadBookConfig.readMenuIconShowText,
                 readSliderMode = ReadBookConfig.readSliderMode,
                 titleBarCustomIcons = ReadBookConfig.titleBarCustomIcons.toImmutableMap(),
@@ -4528,6 +4568,9 @@ class ReadBookViewModel(
         ReadBook.book?.let {
             it.setUseReplaceRule(enabled)
             ReadBook.saveRead()
+            _uiState.update { state ->
+                state.copy(useReplaceRule = enabled, replaceRuleEnabled = enabled)
+            }
             replaceRuleChanged()
         }
     }
@@ -4566,6 +4609,13 @@ class ReadBookViewModel(
                         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.TitleBottomSpacing -> ReadBookConfig.titleBottomSpacing = update.value
             is ConfigUpdate.TitleColor -> ReadBookConfig.titleColor = update.color
+            is ConfigUpdate.TitleColorNight -> ReadBookConfig.titleColorNight = update.color
             is ConfigUpdate.TitleFont -> ReadBookConfig.titleFont = update.path
             is ConfigUpdate.TitleSegType -> ReadBookConfig.titleSegType = update.value
             is ConfigUpdate.TitleSegDistance -> ReadBookConfig.titleSegDistance = update.value
@@ -4611,7 +4662,9 @@ class ReadBookViewModel(
             is ConfigUpdate.HeaderFont -> ReadBookConfig.headerFont = update.path
             is ConfigUpdate.HeaderFontSize -> ReadBookConfig.headerFontSize = update.value
             is ConfigUpdate.TipHeaderColor -> ReadBookConfig.tipHeaderColor = update.color
+            is ConfigUpdate.TipHeaderColorNight -> ReadBookConfig.tipHeaderColorNight = update.color
             is ConfigUpdate.TipFooterColor -> ReadBookConfig.tipFooterColor = update.color
+            is ConfigUpdate.TipFooterColorNight -> ReadBookConfig.tipFooterColorNight = update.color
             is ConfigUpdate.TipDividerColor -> ReadBookConfig.tipDividerColor = update.color
 
             // --- Layout / style ---
@@ -4672,6 +4725,24 @@ class ReadBookViewModel(
                 }
                 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 -> {
                 val value = update.value.coerceIn(0, 1)
                 ReadBookConfig.readMenuColorMode = value
@@ -4774,6 +4845,14 @@ class ReadBookViewModel(
                 }
                 _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 -> {
                 val value = update.value.coerceIn(2, 8)
                 ReadBookConfig.readMenuIconItemsPerRow = value
@@ -4934,6 +5013,13 @@ class ReadBookViewModel(
                 }
                 _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 -> {
                 ReadBookConfig.readMenuPaletteStyle = update.value
                 viewModelScope.launch {
@@ -5221,12 +5307,6 @@ class ReadBookViewModel(
                     readSettingsRepository.setShowSelectMenuIcon(update.value)
                 }
             }
-            is ConfigUpdate.TextSelectMenuFilter -> {
-                ReadConfig.textSelectMenuFilter = update.value
-                viewModelScope.launch {
-                    readSettingsRepository.setTextSelectMenuFilter(update.value)
-                }
-            }
             is ConfigUpdate.ShowReadTitleAddition -> {
                 ReadConfig.showReadTitleAddition = update.value
                 viewModelScope.launch {
@@ -5815,8 +5895,10 @@ class ReadBookViewModel(
 
     private fun toggleTranslation() {
         val book = ReadBook.book ?: return
-        book.setTranslationMode(!book.getTranslationMode())
+        val enabled = !book.getTranslationMode()
+        book.setTranslationMode(enabled)
         book.save()
+        _uiState.update { it.copy(translationMode = enabled) }
         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() {
         viewModelScope.launch {
             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 {
             val textColor = it.textColor
             val headerColor = with(ReadBookConfig) {
-                if (tipHeaderColor == 0) textColor else tipHeaderColor
+                if (resolvedTipHeaderColor == 0) textColor else resolvedTipHeaderColor
             }
             val footerColor = with(ReadBookConfig) {
-                if (tipFooterColor == 0) textColor else tipFooterColor
+                if (resolvedTipFooterColor == 0) textColor else resolvedTipFooterColor
             }
             val tipDividerColor = with(ReadBookConfig) {
                 when (tipDividerColor) {
@@ -455,13 +455,6 @@ class PageView(
         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
 
-import android.annotation.SuppressLint
 import android.content.Context
 import android.graphics.Canvas
 import android.graphics.RectF
 import android.os.Build
+import android.os.Bundle
 import android.util.AttributeSet
 import android.view.MotionEvent
+import android.view.View
 import android.view.ViewConfiguration
 import android.view.WindowInsets
+import android.view.accessibility.AccessibilityNodeInfo
 import android.widget.FrameLayout
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
@@ -123,6 +125,10 @@ class ReadView(
         addView(nextPage)
         addView(curPage)
         addView(prevPage)
+        descendantFocusability = FOCUS_BLOCK_DESCENDANTS
+        importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
+        isClickable = true
+        isFocusable = true
         prevPage.invisible()
         nextPage.invisible()
         curPage.markAsMainView()
@@ -175,7 +181,6 @@ class ReadView(
     /**
      * 触摸事件
      */
-    @SuppressLint("ClickableViewAccessibility")
     override fun onTouchEvent(event: MotionEvent): Boolean {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
             val insets = this.rootWindowInsets.getInsetsIgnoringVisibility(
@@ -240,6 +245,7 @@ class ReadView(
                 pressDown = false
                 if (pageDelegate?.isMoved == false && !isMove) {
                     if (!longPressed && !pressOnTextSelected) {
+                        performClick()
                         val handled = curPage.onClick(startX, startY)
                         if (!handled) {
                             onSingleTapUp()
@@ -271,6 +277,58 @@ class ReadView(
         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) {
         if (isTextSelected || clearSearchResult) {
             curPage.cancelSelect(clearSearchResult)
@@ -573,7 +631,7 @@ class ReadView(
      */
     override fun upContent(relativePosition: Int, resetPageOffset: Boolean) {
         post {
-            curPage.setContentDescription(pageFactory.curPage.text)
+            contentDescription = pageFactory.curPage.text
         }
         if (isScroll && !isAutoPage) {
             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) {
             ReadBookConfig.textAccentColor
-        } else if (isTitle && ReadBookConfig.titleColor != 0) {
-            ReadBookConfig.titleColor
+        } else if (isTitle && ReadBookConfig.resolvedTitleColor != 0) {
+            ReadBookConfig.resolvedTitleColor
         } else {
             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) {
             ReadBookConfig.textAccentColor
         } else {
-            textColor ?: if (textLine.isTitle && ReadBookConfig.titleColor != 0) {
-                ReadBookConfig.titleColor
+            textColor ?: if (textLine.isTitle && ReadBookConfig.resolvedTitleColor != 0) {
+                ReadBookConfig.resolvedTitleColor
             } else {
                 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
         viewHeight = height
         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 adaptSpecialStyle = ReadConfig.adaptSpecialStyle
     private val pageAnim = book.getPageAnim()
+    private val isSingleImageStyle = book.getImageStyle().equals(Book.imgStyleSingle, true)
     private val titleSegType = ReadBookConfig.titleSegType
     private val titleSegDistance = ReadBookConfig.titleSegDistance
     private val titleSegFlag = ReadBookConfig.titleSegFlag
@@ -271,7 +272,6 @@ class TextChapterLayout(
     ) {
         val contents = bookContent.textList
         val imageStyle = book.getImageStyle()
-        val isSingleImageStyle = imageStyle.equals(Book.imgStyleSingle, true)
         val isTextImageStyle = imageStyle.equals(Book.imgStyleText, true)
 
         val allTitleSegments = if (titleMode != 2 || bookChapter.isVolume || contents.isEmpty()) {
@@ -356,16 +356,19 @@ class TextChapterLayout(
         val sb = StringBuffer()
         var isSetTypedImage = false
         var wordCount = 0
-        contents.forEach { content ->
+        contents.forEachIndexed { contentIndex, content ->
             currentCoroutineContext().ensureActive()
+            highlightStyleContext?.bodyContentOffsets?.getOrNull(contentIndex)?.let {
+                bodyHighlightOffset = it
+            }
             if (adaptSpecialStyle) {
                 val text = content.trim()
                 if (text == "[newpage]") {
                     prepareNextPageIfNeed()
-                    return@forEach
+                    return@forEachIndexed
                 } else if (text.startsWith("<usehtml>")) {
                     setTypeHtml(imageStyle, book, text.substring(9, text.lastIndexOf("<")))
-                    return@forEach
+                    return@forEachIndexed
                 }
             }
             var text = content.replace(srcReplaceCharC, srcReplaceCharD)
@@ -1034,8 +1037,9 @@ class TextChapterLayout(
             val textPage = pendingTextPage
             textPage.addLine(textLine)
             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
@@ -1421,13 +1425,14 @@ class TextChapterLayout(
             isTitle = true,
             rules = rules
         )
+        val bodyHighlightText = buildBodyHighlightText(contents)
         val bodyStyles = createHighlightStyles(
-            text = buildBodyHighlightText(contents),
+            text = bodyHighlightText.text,
             isTitle = false,
             rules = rules
         )
         if (titleStyles == null && bodyStyles == null) return null
-        return HighlightStyleContext(titleStyles, bodyStyles)
+        return HighlightStyleContext(titleStyles, bodyStyles, bodyHighlightText.contentOffsets)
     }
 
     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) {
                     val text = content.trim()
                     if (text == "[newpage]" || text.startsWith("<usehtml>")) {
-                        return@forEach
+                        return@forEachIndexed
                     }
                 }
                 append(
@@ -1457,6 +1464,7 @@ class TextChapterLayout(
                 append('\n')
             }
         }
+        return BodyHighlightText(text, contentOffsets)
     }
 
     private fun String.replaceImagesForHighlight(): String {
@@ -1521,6 +1529,7 @@ class TextChapterLayout(
     private data class HighlightStyleContext(
         val titleStyles: Array<CharStyle?>?,
         val bodyStyles: Array<CharStyle?>?,
+        val bodyContentOffsets: IntArray,
     ) {
         fun stylesFor(isTitle: Boolean, offset: Int, length: Int): Array<CharStyle?>? {
             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(
         val rule: HighlightRule,
         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
                 else
                     Icons.Default.Autorenew,
+                contentDescription = stringResource(R.string.ai_generate),
                 enabled = state.presets.isNotEmpty() &&
                         !state.isLoading &&
                         !state.isApplying
@@ -83,6 +84,7 @@ fun AiTextRewriteSheet(
             MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ConfirmAiTextRewrite) },
                 icon = Icons.Default.Save,
+                contentDescription = stringResource(R.string.save),
                 enabled = !state.isLoading &&
                         !state.isApplying &&
                         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 {
                     MediumPlainButton(
                         onClick = { showOptionsMenu = true },
-                        icon = Icons.Default.MoreVert
+                icon = Icons.Default.MoreVert,
+                contentDescription = stringResource(R.string.more_menu)
                     )
                     RoundDropdownMenu(
                         expanded = showOptionsMenu,
@@ -117,11 +118,15 @@ fun ChangeChapterSourceSheet(
                 Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                     MediumPlainButton(
                         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(
                         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) },
                             icon = Icons.Default.PushPin,
                             tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline,
-                            contentDescription = null
+                            contentDescription = stringResource(R.string.a11y_pin_source)
                         )
                     },
                     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.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
@@ -67,7 +68,7 @@ fun ClickActionConfigSheet(
     Box(
         modifier = Modifier
             .fillMaxSize()
-            .background(LegadoTheme.colorScheme.scrim.copy(alpha = 0.6f))
+            .background(LegadoTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.4f))
             .clickable(onClick = onDismissRequest),
     ) {
         Column(
@@ -75,13 +76,9 @@ fun ClickActionConfigSheet(
                 .fillMaxSize()
                 .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(
                 modifier = Modifier
                     .weight(1f)
@@ -194,9 +191,9 @@ fun ClickActionConfigSheet(
                                 selectingPrefKey?.let { key ->
                                     scope.launch {
                                         readSettingsRepository.setClickAction(key, selectedAction)
+                                        selectingPrefKey = null
                                     }
                                 }
-                                selectingPrefKey = null
                             }
                             .padding(horizontal = 24.dp, vertical = 12.dp),
                         style = LegadoTheme.typography.bodyLarge,
@@ -216,8 +213,8 @@ private fun ClickAreaCell(
     GlassCard(
         modifier = modifier,
         onClick = onClick,
-        containerColor = LegadoTheme.colorScheme.surfaceVariant
-            .copy(alpha = 0.5f),
+        containerColor = LegadoTheme.colorScheme.surfaceContainer
+            .copy(alpha = 0.9f),
     ) {
         Box(
             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.setValue
 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.Modifier
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.TextRange
 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.theme.LegadoTheme
 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.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.utils.sendToClip
 
 @Composable
 fun ContentEditSheet(
@@ -43,7 +44,6 @@ fun ContentEditSheet(
     onIntent: (ReadBookIntent) -> Unit,
     onDismissRequest: () -> Unit,
 ) {
-    val context = LocalContext.current
     val editorState = remember { TextFieldState() }
     val editorScrollState = rememberScrollState()
     var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
@@ -98,40 +98,34 @@ fun ContentEditSheet(
         show = show,
         onDismissRequest = onDismissRequest,
         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(
             modifier = Modifier
                 .fillMaxWidth()
                 .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) {
                 Box(
                     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
 
 private const val CHINESE_CONVERT_ID = -1L
+private const val RE_SEGMENT_ID = -2L
 
 @Composable
 fun EffectiveReplacesSheet(
@@ -51,16 +52,18 @@ fun EffectiveReplacesSheet(
     val scope = rememberCoroutineScope()
     val chineseConvertActive = ReadConfig.chineseConverterType > 0
     val chineseConvertItem = remember { ReplaceRule(CHINESE_CONVERT_ID, "繁简转换") }
+    val reSegmentActive = ReadBook.book?.getReSegment() == true
+    val reSegmentItem = remember { ReplaceRule(RE_SEGMENT_ID, "") }
 
     val effectiveRules = remember(show) {
         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 = {
                             if (rule.id == CHINESE_CONVERT_ID) {
                                 onNavigateToTextEffects()
-                            } else {
+                            } else if (rule.id != RE_SEGMENT_ID) {
                                 onOpenReplaceEditor(rule.id, rule.pattern)
                             }
                         },
@@ -109,12 +112,16 @@ fun EffectiveReplacesSheet(
                         ) {
                             Column(modifier = Modifier.weight(1f)) {
                                 AppText(
-                                    text = rule.name,
+                                    text = if (rule.id == RE_SEGMENT_ID) {
+                                        stringResource(R.string.re_segment)
+                                    } else {
+                                        rule.name
+                                    },
                                     style = LegadoTheme.typography.labelLargeEmphasized,
                                     maxLines = 1,
                                     overflow = TextOverflow.Ellipsis,
                                 )
-                                if (rule.id != CHINESE_CONVERT_ID) {
+                                if (rule.id >= 0) {
                                     Row(verticalAlignment = Alignment.CenterVertically) {
                                         AppText(
                                             text = rule.pattern,
@@ -146,16 +153,21 @@ fun EffectiveReplacesSheet(
                                 onClick = {
                                     disabledIds = disabledIds + rule.id
                                     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
                                             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.SectionTitle
 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.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.TinyDropdownSettingItem
 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_FOOTER = 8
 private const val COLOR_DIVIDER = 9
+private const val COLOR_HEADER_NIGHT = 10
+private const val COLOR_FOOTER_NIGHT = 11
 
 @Composable
 internal fun HeaderFooterPage(
@@ -293,19 +298,33 @@ internal fun HeaderFooterPage(
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderRight(it)))
                             },
                         )
-                        TinyColorSettingItem(
+                        TinyColorModeSettingItem(
                             title = stringResource(R.string.header_color),
-                            colorValue = if (ReadBookConfig.tipHeaderColor != 0) {
+                            dayColor = if (ReadBookConfig.tipHeaderColor != 0) {
                                 ReadBookConfig.tipHeaderColor
                             } else {
                                 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 {
-                                    ReadBookConfig.textColor
+                                    colorPickerId = COLOR_HEADER
+                                    colorPickerInitial = if (ReadBookConfig.tipHeaderColor != 0) {
+                                        ReadBookConfig.tipHeaderColor
+                                    } else {
+                                        ReadBookConfig.textColor
+                                    }
                                 }
                                 showColorPicker = true
                             },
@@ -423,19 +442,33 @@ internal fun HeaderFooterPage(
                                 onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterRight(it)))
                             },
                         )
-                        TinyColorSettingItem(
+                        TinyColorModeSettingItem(
                             title = stringResource(R.string.footer_color),
-                            colorValue = if (ReadBookConfig.tipFooterColor != 0) {
+                            dayColor = if (ReadBookConfig.tipFooterColor != 0) {
                                 ReadBookConfig.tipFooterColor
                             } else {
                                 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 {
-                                    ReadBookConfig.textColor
+                                    colorPickerId = COLOR_FOOTER
+                                    colorPickerInitial = if (ReadBookConfig.tipFooterColor != 0) {
+                                        ReadBookConfig.tipFooterColor
+                                    } else {
+                                        ReadBookConfig.textColor
+                                    }
                                 }
                                 showColorPicker = true
                             },
@@ -555,10 +588,18 @@ internal fun HeaderFooterPage(
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColor(color)))
                 }
 
+                COLOR_HEADER_NIGHT -> {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipHeaderColorNight(color)))
+                }
+
                 COLOR_FOOTER -> {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColor(color)))
                 }
 
+                COLOR_FOOTER_NIGHT -> {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.TipFooterColorNight(color)))
+                }
+
                 COLOR_DIVIDER -> {
                     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.size
 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.material.icons.Icons
 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.RoundDropdownMenuItem
 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 sh.calvin.reorderable.ReorderableItem
 import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -121,7 +122,8 @@ fun HighlightRuleConfigSheet(
         startAction = {
             SmallTonalButton(
                 onClick = { onIntent(ReadBookIntent.AddHighlightRule) },
-                icon = Icons.Default.Add
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.add)
             )
         },
         endAction = {
@@ -129,7 +131,8 @@ fun HighlightRuleConfigSheet(
             Box {
                 SmallTonalButton(
                     onClick = { expanded = true },
-                    icon = Icons.Default.MoreVert
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu)
                 )
                 RoundDropdownMenu(
                     expanded = expanded,
@@ -170,7 +173,7 @@ fun HighlightRuleConfigSheet(
                 verticalArrangement = Arrangement.spacedBy(8.dp),
                 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 ->
                         HighlightRuleItem(
                             rule = rule,
@@ -184,6 +187,13 @@ fun HighlightRuleConfigSheet(
                                 onIntent(ReadBookIntent.RequestDeleteHighlightRule(rule))
                             },
                             modifier = Modifier
+                                .reorderAccessibility(
+                                    index = index,
+                                    itemCount = state.rules.size,
+                                ) { from, to ->
+                                    onIntent(ReadBookIntent.MoveHighlightRule(from, to))
+                                    onIntent(ReadBookIntent.SaveHighlightRuleOrder)
+                                }
                                 .longPressDraggableHandle()
                                 .zIndex(if (isDragging) 1f else 0f)
                                 .animateItem(),
@@ -251,11 +261,13 @@ private fun HighlightRuleItem(
                 )
                 SmallTonalButton(
                     onClick = onEditClick,
-                    icon = Icons.Default.Edit
+                    icon = Icons.Default.Edit,
+                    contentDescription = stringResource(R.string.edit)
                 )
                 SmallTonalButton(
                     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(
                     Icons.Default.Done,
-                    contentDescription = null,
+                    contentDescription = stringResource(R.string.save),
                 )
             }
         },

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