瀏覽代碼

feat: 优化了一下听书功能

HapeLee 3 月之前
父節點
當前提交
b5ea1482a1
共有 23 個文件被更改,包括 1289 次插入 和 237 次删除
  1. 2 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 20 1
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  3. 41 39
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 2 0
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt
  5. 2 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  6. 363 113
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  7. 203 42
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt
  8. 8 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  9. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  10. 11 7
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  11. 22 8
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  12. 33 3
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  13. 19 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt
  14. 63 0
      app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheContract.kt
  15. 193 0
      app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheScreen.kt
  16. 233 0
      app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheViewModel.kt
  17. 11 12
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt
  18. 2 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  19. 22 12
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  20. 4 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  21. 2 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  22. 16 0
      app/src/main/res/values-zh-rCN/strings.xml
  23. 16 0
      app/src/main/res/values/strings.xml

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

@@ -161,6 +161,7 @@ object PreferKey {
     const val audioPlayWakeLock = "audioPlayWakeLock"
     const val readAloudWakeLock = "readAloudWakeLock"
     const val showReadAloudCapsule = "showReadAloudCapsule"
+    const val capsuleAutoCollapse = "capsuleAutoCollapse"
     const val defaultReadAloudInterface = "defaultReadAloudInterface"
     const val showLastUpdateTime = "showLastUpdateTime"
     const val showWaitUpCount = "showWaitUpCount"
@@ -376,6 +377,7 @@ object PreferKey {
     const val selectVibrator = "selectVibrator"
     const val audioCacheCleanTime = "audioCacheCleanTime"
     const val audioPreDownloadNum = "audioPreDownloadNum"
+    const val ttsPreSynthesisConcurrency = "ttsPreSynthesisConcurrency"
     const val ttsParagraphInterval = "ttsParagraphInterval"
     const val customTagColors = "customTagColors"
     const val enableCustomTagColors = "enableCustomTagColors"

+ 20 - 1
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -5,9 +5,9 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
-import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsUpdate
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsString
@@ -52,6 +52,11 @@ class ReadAloudSettingsRepository(
                 settingsRepository.putInt(PreferKey.contentSelectSpeakMod, update.value)
             is ReadAloudSettingsUpdate.AudioPreDownloadNum ->
                 settingsRepository.putInt(PreferKey.audioPreDownloadNum, update.value)
+            is ReadAloudSettingsUpdate.PreSynthesisConcurrency ->
+                settingsRepository.putInt(PreferKey.ttsPreSynthesisConcurrency, update.value)
+
+            is ReadAloudSettingsUpdate.CapsuleAutoCollapse ->
+                settingsRepository.putBoolean(PreferKey.capsuleAutoCollapse, update.value)
         }
     }
 
@@ -142,6 +147,14 @@ class ReadAloudSettingsRepository(
         )
     }
 
+    suspend fun setPreSynthesisConcurrency(value: Int) {
+        settingsRepository.putInt(PreferKey.ttsPreSynthesisConcurrency, value.coerceIn(1, 8))
+    }
+
+    suspend fun setCapsuleAutoCollapse(value: Boolean) {
+        settingsRepository.putBoolean(PreferKey.capsuleAutoCollapse, value)
+    }
+
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
             ttsEngine = compatDsString(PreferKey.ttsEngine),
@@ -153,6 +166,7 @@ class ReadAloudSettingsRepository(
             pauseReadAloudWhilePhoneCalls = compatDsValue(Keys.PauseReadAloudWhilePhoneCalls, false),
             readAloudWakeLock = compatDsValue(Keys.ReadAloudWakeLock, false),
             showReadAloudCapsule = compatDsValue(Keys.ShowReadAloudCapsule, true),
+            capsuleAutoCollapse = compatDsValue(Keys.CapsuleAutoCollapse, true),
             capsuleOffsetX = compatDsValue(Keys.CapsuleOffsetX, 0f),
             capsuleOffsetY = compatDsValue(Keys.CapsuleOffsetY, 0f),
             mediaButtonPerNext = compatDsValue(Keys.MediaButtonPerNext, false),
@@ -168,6 +182,7 @@ class ReadAloudSettingsRepository(
             defaultInterface = compatDsValue(Keys.DefaultInterface, DEFAULT_INTERFACE_CLASSIC),
             contentSelectSpeakMode = compatDsValue(Keys.ContentSelectSpeakMode, 0),
             audioPreDownloadNum = compatDsValue(Keys.AudioPreDownloadNum, 10),
+            ttsPreSynthesisConcurrency = compatDsValue(Keys.PreSynthesisConcurrency, 3),
         )
     }
 
@@ -185,6 +200,7 @@ class ReadAloudSettingsRepository(
             booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
         val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
         val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
+        val CapsuleAutoCollapse = booleanPreferencesKey(PreferKey.capsuleAutoCollapse)
         val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
         val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
         val MediaButtonPerNext = booleanPreferencesKey(KEY_MEDIA_BUTTON_PER_NEXT)
@@ -206,6 +222,9 @@ class ReadAloudSettingsRepository(
         val AudioPreDownloadNum = androidx.datastore.preferences.core.intPreferencesKey(
             PreferKey.audioPreDownloadNum
         )
+        val PreSynthesisConcurrency = androidx.datastore.preferences.core.intPreferencesKey(
+            PreferKey.ttsPreSynthesisConcurrency
+        )
     }
 
     companion object {

+ 41 - 39
app/src/main/java/io/legado/app/di/appModule.kt

@@ -13,35 +13,36 @@ import io.legado.app.data.repository.AiProfileRepository
 import io.legado.app.data.repository.AiPromptPresetRepository
 import io.legado.app.data.repository.AiTextRepositoryImpl
 import io.legado.app.data.repository.AiToolRepository
-import io.legado.app.data.repository.AppStartupRepository
 import io.legado.app.data.repository.AppLocaleRepository
 import io.legado.app.data.repository.AppShellSettingsRepository
+import io.legado.app.data.repository.AppStartupRepository
 import io.legado.app.data.repository.AppUiConfigurationRepository
 import io.legado.app.data.repository.BackupRestoreRepository
 import io.legado.app.data.repository.BackupSettingsRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
-import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookContentProcessRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
-import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookGroupMutationRepository
+import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.BookKnowledgeRepository
-import io.legado.app.data.repository.ChapterSpeechRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceRepository
-import io.legado.app.data.repository.ChangeSourceSettingsRepository
-import io.legado.app.data.repository.CheckSourceSettingsRepository
 import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.BookshelfSettingsRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
+import io.legado.app.data.repository.ChangeSourceSettingsRepository
+import io.legado.app.data.repository.ChapterSpeechRepository
+import io.legado.app.data.repository.CheckSourceSettingsRepository
+import io.legado.app.data.repository.CloudTtsEngineRepository
 import io.legado.app.data.repository.CoverAlbumRepository
 import io.legado.app.data.repository.CoverSettingsRepository
 import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
 import io.legado.app.data.repository.DictionaryRepositoryImpl
-import io.legado.app.data.repository.DirectLinkUploadRepository
 import io.legado.app.data.repository.DirectLinkSettingsRepository
+import io.legado.app.data.repository.DirectLinkUploadRepository
 import io.legado.app.data.repository.DownloadCacheSettingsRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
@@ -49,39 +50,39 @@ import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.HomeDashboardRepository
 import io.legado.app.data.repository.HomepageModulesRepository
 import io.legado.app.data.repository.HomepageSettingsRepository
+import io.legado.app.data.repository.HttpTtsEngineRepository
 import io.legado.app.data.repository.ImportBookSettingsRepository
 import io.legado.app.data.repository.LabSettingsRepository
-import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.LocalPasswordRepository
+import io.legado.app.data.repository.MangaSettingsRepository
 import io.legado.app.data.repository.OtherConfigSystemRepository
 import io.legado.app.data.repository.OtherSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudVoiceRepository
-import io.legado.app.data.repository.CloudTtsEngineRepository
-import io.legado.app.data.security.CloudTtsCredentialCipher
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.data.repository.RemoteBookRepository
 import io.legado.app.data.repository.ReplaceRuleRepository
-import io.legado.app.data.repository.RssRepository
 import io.legado.app.data.repository.RssFavoriteRepository
+import io.legado.app.data.repository.RssRepository
 import io.legado.app.data.repository.RuleSubscriptionRepository
 import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
-import io.legado.app.data.repository.ThemeSettingsRepository
 import io.legado.app.data.repository.TagGroupRuleApplier
 import io.legado.app.data.repository.ThemePackageSettingsRepository
-import io.legado.app.data.repository.TxtTocRuleRepository
+import io.legado.app.data.repository.ThemeSettingsRepository
 import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.TranslationSettingsRepository
+import io.legado.app.data.repository.TxtTocRuleRepository
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
+import io.legado.app.data.security.CloudTtsCredentialCipher
 import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiChatGateway
 import io.legado.app.domain.gateway.AiMemoryGateway
@@ -89,9 +90,9 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiToolGateway
-import io.legado.app.domain.gateway.AppStartupGateway
 import io.legado.app.domain.gateway.AppLocaleGateway
 import io.legado.app.domain.gateway.AppShellSettingsGateway
+import io.legado.app.domain.gateway.AppStartupGateway
 import io.legado.app.domain.gateway.AppUiConfigurationGateway
 import io.legado.app.domain.gateway.BackupRestoreGateway
 import io.legado.app.domain.gateway.BackupSettingsGateway
@@ -99,17 +100,17 @@ import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
 import io.legado.app.domain.gateway.BookExportSettingsGateway
-import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.BookGroupMutationGateway
-import io.legado.app.domain.gateway.ChapterSpeechGateway
+import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
 import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
+import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.gateway.CheckSourceSettingsGateway
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.CoverAlbumGateway
 import io.legado.app.domain.gateway.CoverSettingsGateway
-import io.legado.app.domain.gateway.ThemePackageSettingsGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.DirectLinkSettingsGateway
@@ -118,30 +119,31 @@ import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomeDashboardGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
 import io.legado.app.domain.gateway.HomepageSettingsGateway
+import io.legado.app.domain.gateway.HttpTtsEngineGateway
 import io.legado.app.domain.gateway.ImportBookSettingsGateway
 import io.legado.app.domain.gateway.LabSettingsGateway
-import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.LocalPasswordGateway
+import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherConfigSystemGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
-import io.legado.app.domain.gateway.ReadingProgressGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudVoiceGateway
-import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
-import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadStyleGateway
+import io.legado.app.domain.gateway.ReadingProgressGateway
+import io.legado.app.domain.gateway.ThemePackageSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.TranslationSettingsGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
-import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AiChatGenerationUseCase
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
+import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -161,31 +163,28 @@ import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.HomeDashboardUseCase
 import io.legado.app.domain.usecase.ImportBookshelfUseCase
+import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
+import io.legado.app.domain.usecase.RefineSpeechWithAiUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.ResolveLocalSpeakersUseCase
-import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
-import io.legado.app.domain.usecase.RefineSpeechWithAiUseCase
+import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
-import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
 import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
-import io.legado.app.domain.gateway.HttpTtsEngineGateway
-import io.legado.app.data.repository.HttpTtsEngineRepository
 import io.legado.app.domain.usecase.TranslateChapterUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
-import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
 import io.legado.app.help.coil.CoverInterceptor
 import io.legado.app.help.config.ThemePackageManager
-import io.legado.app.ui.config.readConfig.ApplyReadSettingUseCase
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClientManga
+import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.ui.about.AboutViewModel
 import io.legado.app.ui.ai.chat.AiChatViewModel
 import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
@@ -202,21 +201,22 @@ import io.legado.app.ui.book.import.remote.ServerConfigViewModel
 import io.legado.app.ui.book.import.remote.ServersViewModel
 import io.legado.app.ui.book.info.BookInfoViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterDetailViewModel
-import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterListViewModel
-import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
-import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
-import io.legado.app.ui.book.knowledge.BookEventListViewModel
+import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
 import io.legado.app.ui.book.knowledge.BookEventDetailViewModel
+import io.legado.app.ui.book.knowledge.BookEventListViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
 import io.legado.app.ui.book.manage.BookshelfManageScreenViewModel
 import io.legado.app.ui.book.manga.ReadMangaViewModel
 import io.legado.app.ui.book.read.ReadBookViewModel
+import io.legado.app.ui.book.readRecord.ReadRecordOverviewViewModel
+import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+import io.legado.app.ui.book.readaloud.cache.TtsCacheViewModel
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingViewModel
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
-import io.legado.app.ui.book.readRecord.ReadRecordOverviewViewModel
-import io.legado.app.ui.book.readRecord.ReadRecordViewModel
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.book.search.SearchViewModel
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.toc.TocViewModel
@@ -225,19 +225,20 @@ import io.legado.app.ui.book.toc.rule.preview.TxtTocRulePreviewViewModel
 import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
-import io.legado.app.ui.config.ai.summary.AiSummaryConfigViewModel
 import io.legado.app.ui.config.ai.prompt.AiPromptConfigViewModel
+import io.legado.app.ui.config.ai.summary.AiSummaryConfigViewModel
 import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
+import io.legado.app.ui.config.customTheme.CustomThemeViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
+import io.legado.app.ui.config.labConfig.LabConfigViewModel
 import io.legado.app.ui.config.otherConfig.OtherConfigViewModel
-import io.legado.app.ui.config.customTheme.CustomThemeViewModel
+import io.legado.app.ui.config.readConfig.ApplyReadSettingUseCase
 import io.legado.app.ui.config.readConfig.ReadConfigViewModel
 import io.legado.app.ui.config.themeConfig.ThemeConfigViewModel
 import io.legado.app.ui.config.themeManage.ThemeManageViewModel
-import io.legado.app.ui.config.labConfig.LabConfigViewModel
 import io.legado.app.ui.config.translation.TranslationConfigViewModel
 import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
@@ -541,6 +542,7 @@ val appModule = module {
         )
     }
     viewModelOf(::CloudTtsViewModel)
+    viewModelOf(::TtsCacheViewModel)
     singleOf(::ReadAloudPlayerCoordinator)
     viewModelOf(::ReadAloudPlayerViewModel)
     viewModel { (bookUrl: String, entryId: String?) ->

+ 2 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadAloudSettingsGateway.kt

@@ -27,4 +27,6 @@ sealed interface ReadAloudSettingsUpdate {
     data class SpeechRate(val value: Int) : ReadAloudSettingsUpdate
     data class ContentSelectSpeakMode(val value: Int) : ReadAloudSettingsUpdate
     data class AudioPreDownloadNum(val value: Int) : ReadAloudSettingsUpdate
+    data class PreSynthesisConcurrency(val value: Int) : ReadAloudSettingsUpdate
+    data class CapsuleAutoCollapse(val value: Boolean) : ReadAloudSettingsUpdate
 }

+ 2 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -10,6 +10,7 @@ data class ReadAloudSettings(
     val pauseReadAloudWhilePhoneCalls: Boolean = false,
     val readAloudWakeLock: Boolean = false,
     val showReadAloudCapsule: Boolean = true,
+    val capsuleAutoCollapse: Boolean = true,
     val capsuleOffsetX: Float = 0f,
     val capsuleOffsetY: Float = 0f,
     val mediaButtonPerNext: Boolean = false,
@@ -24,4 +25,5 @@ data class ReadAloudSettings(
     val defaultInterface: String = "classic",
     val contentSelectSpeakMode: Int = 0,
     val audioPreDownloadNum: Int = 10,
+    val ttsPreSynthesisConcurrency: Int = 3,
 )

+ 363 - 113
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -32,6 +32,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.HttpTTS
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
@@ -51,12 +52,11 @@ import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.exoplayer.InputStreamDataSource
 import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.readaloud.playback.SystemTtsFileSynthesizer
+import io.legado.app.help.readaloud.playback.CharacterPerformanceInstructionBuilder
 import io.legado.app.help.readaloud.playback.CloudTtsAudioSynthesizer
 import io.legado.app.help.readaloud.playback.CloudTtsEmotionMapper
-import io.legado.app.help.readaloud.playback.CharacterPerformanceInstructionBuilder
 import io.legado.app.help.readaloud.playback.CloudTtsRoleInstructionMapper
-import io.legado.app.domain.gateway.CloudTtsEngineGateway
+import io.legado.app.help.readaloud.playback.SystemTtsFileSynthesizer
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeUrl
@@ -72,7 +72,10 @@ import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
@@ -82,9 +85,9 @@ import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import okhttp3.Response
-import org.mozilla.javascript.WrappedException
 import org.koin.core.context.GlobalContext
 import org.koin.java.KoinJavaComponent.get
+import org.mozilla.javascript.WrappedException
 import splitties.init.appCtx
 import java.io.File
 import java.io.InputStream
@@ -142,6 +145,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
     private var speechRate: Int = speechRatePlay + 5
     private var downloadTask: Coroutine<*>? = null
+    private var preDownloadJob: Job? = null
     private var playIndexJob: Job? = null
     private var downloadErrorNo: Int = 0
     private var playErrorNo = 0
@@ -168,6 +172,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     override fun onDestroy() {
         super.onDestroy()
         downloadTask?.cancel()
+        preDownloadJob?.cancel()
         exoPlayer.release()
         cache.release()
         Coroutine.async {
@@ -196,6 +201,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     override fun playStop() {
         exoPlayer.stop()
         playIndexJob?.cancel()
+        preDownloadJob?.cancel()
     }
 
     private fun updateNextPos() {
@@ -216,11 +222,12 @@ class HttpReadAloudService : BaseReadAloudService(),
     private fun downloadAndPlayAudios() {
         exoPlayer.clearMediaItems()
         downloadTask?.cancel()
+        preDownloadJob?.cancel()
         downloadTask = execute {
             downloadTaskActiveLock.withLock {
                 ensureActive()
                 val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
-                
+
                 contentList.forEachIndexed { index, content ->
                     ensureActive()
                     if (index < nowSpeak) return@forEachIndexed
@@ -228,28 +235,12 @@ class HttpReadAloudService : BaseReadAloudService(),
                     if (paragraphStartPos > 0 && index == nowSpeak) {
                         text = text.substring(paragraphStartPos)
                     }
-                    // 计算文件名时,会自动调用修正后的 md5SpeakFileName
                     val routedVoice = voiceForCue(playbackQueue, index, httpTts)
                     val cue = playbackQueue.cues.getOrNull(index)
                     val cueEmotion = cue?.emotion.orEmpty()
                     val characterPerformance = cue?.characterPerformance
                     val cueRoleType = cue?.roleType ?: SpeechRoleType.Unknown
-                    val itemHttpTts = routedVoice.takeIf {
-                        it.engineType == ReadAloudVoice.ENGINE_HTTP
-                    }?.engineId?.toLongOrNull()?.let(appDb.httpTTSDao::get) ?: httpTts
-                    val sourceKey = when (routedVoice.engineType) {
-                        ReadAloudVoice.ENGINE_SYSTEM ->
-                            "system:${routedVoice.id}:${routedVoice.revision}:${routedVoice.engineId}:${routedVoice.speakerId}"
-                        ReadAloudVoice.ENGINE_CLOUD ->
-                            "cloud:${routedVoice.id}:${routedVoice.revision}:" +
-                                "${CloudTtsEmotionMapper.VERSION}:$cueEmotion:" +
-                                "${CharacterPerformanceInstructionBuilder.VERSION}:" +
-                                "${characterPerformance?.characterId.orEmpty()}:" +
-                                "${characterPerformance?.updatedAt.orZero()}:" +
-                                "${CloudTtsRoleInstructionMapper.VERSION}:" +
-                                cueRoleType.storageValue
-                        else -> itemHttpTts.url
-                    }
+                    val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
                     val fileName = md5SpeakFileName(text, sourceKey = sourceKey)
                     val speakText = text.replace(AppPattern.notReadAloudRegex, "")
                     if (speakText.isEmpty()) {
@@ -257,49 +248,57 @@ class HttpReadAloudService : BaseReadAloudService(),
                         createSilentSound(fileName)
                     } else if (!hasSpeakFile(fileName)) {
                         runCatching {
-                            if (routedVoice.engineType == ReadAloudVoice.ENGINE_SYSTEM) {
-                                val output = getSpeakFileAsMd5(fileName)
-                                val config = runCatching {
-                                    GSON.fromJson(
-                                        routedVoice.traitsJson,
-                                        SystemTtsVoiceConfig::class.java,
-                                    )
-                                }.getOrNull() ?: SystemTtsVoiceConfig()
-                                val globalRate = if (readAloudSettings.ttsFollowSys) {
-                                    1f
-                                } else {
-                                    (readAloudSettings.ttsSpeechRate + 5) / 10f
-                                }
-                                if (!systemTtsFileSynthesizer.synthesize(
-                                        routedVoice.engineId,
-                                        routedVoice.speakerId,
-                                        speakText,
-                                        output,
-                                        config.speechRate ?: globalRate,
-                                        config.pitch ?: 1f,
-                                    )
-                                ) {
-                                    createSilentSound(fileName)
+                            when (routedVoice.engineType) {
+                                ReadAloudVoice.ENGINE_SYSTEM -> {
+                                    val output = getSpeakFileAsMd5(fileName)
+                                    val config = runCatching {
+                                        GSON.fromJson(
+                                            routedVoice.traitsJson,
+                                            SystemTtsVoiceConfig::class.java,
+                                        )
+                                    }.getOrNull() ?: SystemTtsVoiceConfig()
+                                    val globalRate = if (readAloudSettings.ttsFollowSys) {
+                                        1f
+                                    } else {
+                                        (readAloudSettings.ttsSpeechRate + 5) / 10f
+                                    }
+                                    if (!systemTtsFileSynthesizer.synthesize(
+                                            routedVoice.engineId,
+                                            routedVoice.speakerId,
+                                            speakText,
+                                            output,
+                                            config.speechRate ?: globalRate,
+                                            config.pitch ?: 1f,
+                                        )
+                                    ) {
+                                        createSilentSound(fileName)
+                                    }
                                 }
-                            } else if (routedVoice.engineType == ReadAloudVoice.ENGINE_CLOUD) {
-                                val output = getSpeakFileAsMd5(fileName)
-                                if (!cloudTtsAudioSynthesizer.synthesize(
-                                        routedVoice,
-                                        speakText,
-                                        output,
-                                        styleOverride = cueEmotion,
-                                        characterPerformance = characterPerformance,
-                                        roleType = cueRoleType,
-                                    )
-                                ) {
-                                    createSilentSound(fileName)
+
+                                ReadAloudVoice.ENGINE_CLOUD -> {
+                                    val output = getSpeakFileAsMd5(fileName)
+                                    if (!cloudTtsAudioSynthesizer.synthesize(
+                                            routedVoice,
+                                            speakText,
+                                            output,
+                                            styleOverride = cueEmotion,
+                                            characterPerformance = characterPerformance,
+                                            roleType = cueRoleType,
+                                        )
+                                    ) {
+                                        createSilentSound(fileName)
+                                    }
                                 }
-                            } else {
-                                val inputStream = getSpeakStream(itemHttpTts, speakText)
-                                if (inputStream != null) {
-                                    createSpeakFile(fileName, inputStream)
-                                } else {
-                                    createSilentSound(fileName)
+
+                                else -> {
+                                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                                        ?.let(appDb.httpTTSDao::get) ?: httpTts
+                                    val inputStream = getSpeakStream(itemHttpTts, speakText)
+                                    if (inputStream != null) {
+                                        createSpeakFile(fileName, inputStream)
+                                    } else {
+                                        createSilentSound(fileName)
+                                    }
                                 }
                             }
                         }.onFailure {
@@ -310,6 +309,9 @@ class HttpReadAloudService : BaseReadAloudService(),
                             return@execute
                         }
                     }
+                    if (speakText.isNotEmpty() && hasSpeakFile(fileName)) {
+                        writeTextIndexEntry(fileName, speakText)
+                    }
                     val file = getSpeakFileAsMd5(fileName)
                     val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
                     launch(Main) {
@@ -319,6 +321,8 @@ class HttpReadAloudService : BaseReadAloudService(),
                                 if (!pause) {
                                     exoPlayer.prepare()
                                 }
+                                // 当前章开始播放后,立即异步启动后续章节预合成
+                                launchPreDownload(httpTts)
                             }
                         } else {
                             if (exoPlayer.mediaItemCount == 0) {
@@ -326,19 +330,31 @@ class HttpReadAloudService : BaseReadAloudService(),
                                 if (!pause) {
                                     exoPlayer.prepare()
                                 }
+                                // 当前章开始播放后,立即异步启动后续章节预合成
+                                launchPreDownload(httpTts)
                             } else {
                                 exoPlayer.addMediaItem(mediaItem)
                             }
                         }
                     }
                 }
-                preDownloadAudios(httpTts)
             }
         }.onError {
             AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
         }
     }
 
+    /**
+     * 异步启动后续章节的预合成,与当前章节播放并行。
+     * 不持有 downloadTaskActiveLock,不阻塞当前章节的合成和播放。
+     */
+    private fun launchPreDownload(httpTts: HttpTTS) {
+        preDownloadJob?.cancel()
+        preDownloadJob = lifecycleScope.launch {
+            preDownloadAudios(httpTts)
+        }
+    }
+
     private suspend fun getPreDownloadChapter(
         book: Book,
         chapter: BookChapter,
@@ -388,53 +404,196 @@ class HttpReadAloudService : BaseReadAloudService(),
         val book = ReadBook.book ?: return
         val currentIdx = ReadBook.durChapterIndex
         val limit = readAloudSettings.audioPreDownloadNum
-        
+        val concurrency = readAloudSettings.ttsPreSynthesisConcurrency.coerceIn(1, 8)
+        var consecutiveFailures = 0
+
         try {
             for (i in 1..limit) {
                 currentCoroutineContext().ensureActive()
-                
+                if (consecutiveFailures >= 3) {
+                    AppLog.put("TTS预合成连续失败${consecutiveFailures}章,已停止预合成")
+                    break
+                }
                 val targetIndex = currentIdx + i
                 val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
-                
                 val prepared = getPreDownloadChapter(book, chapter) ?: continue
+                val chapterFailed = synthesizeChapterCues(prepared, httpTts, concurrency)
+                consecutiveFailures = if (chapterFailed) consecutiveFailures + 1 else 0
+            }
+        } catch (e: Exception) {
+            AppLog.put("听书预下载异常: ${e.localizedMessage}", e)
+        }
+    }
+
+    /**
+     * 并行合成一个章节的所有 cue,通过 Semaphore 控制并发。
+     * 返回 true 表示该章节合成失败(超过半数 cue 失败)。
+     */
+    private suspend fun synthesizeChapterCues(
+        prepared: PreDownloadChapter,
+        httpTts: HttpTTS,
+        concurrency: Int,
+    ): Boolean = coroutineScope {
+        val semaphore = kotlinx.coroutines.sync.Semaphore(concurrency)
+        var failedCount = 0
+        val totalCues = prepared.contentList.size
 
-                prepared.contentList.forEachIndexed { index, content ->
-                    currentCoroutineContext().ensureActive()
+        prepared.contentList.mapIndexed { index, content ->
+            async {
+                semaphore.acquire()
+                try {
                     val routedVoice = voiceForCue(prepared.queue, index, httpTts)
-                    if (routedVoice.engineType != ReadAloudVoice.ENGINE_HTTP) {
-                        return@forEachIndexed
+                    if (routedVoice.engineType == ReadAloudVoice.ENGINE_SYSTEM) {
+                        return@async
                     }
-                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
-                        ?.let(appDb.httpTTSDao::get) ?: httpTts
+                    val cue = prepared.queue.cues.getOrNull(index)
+                    val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
                     val fileName = md5SpeakFileName(
-                        content,
-                        prepared.textChapter,
-                        itemHttpTts,
+                        content, prepared.textChapter, sourceKey = sourceKey,
                     )
-                    
-                    val speakText = content.replace(AppPattern.notReadAloudRegex, "")
-                    if (speakText.isEmpty()) {
+                    if (hasSpeakFile(fileName)) return@async
+
+                    val success = synthesizeSingleCueWithRetry(
+                        routedVoice, cue, content, prepared.textChapter, httpTts,
+                    )
+                    if (!success) {
                         createSilentSound(fileName)
-                    } else if (!hasSpeakFile(fileName)) {
-                        runCatching {
-                            val inputStream = getSpeakStream(itemHttpTts, speakText)
-                            if (inputStream != null) {
-                                createSpeakFile(fileName, inputStream)
-                            } else {
-                                createSilentSound(fileName)
-                            }
-                        }
+                        failedCount++
                     }
+                } finally {
+                    semaphore.release()
                 }
             }
-        } catch (e: Exception) {
-            AppLog.put("听书预下载异常: ${e.localizedMessage}", e)
+        }.awaitAll()
+
+        failedCount > totalCues / 2
+    }
+
+    /**
+     * 单个 cue 合成 + 重试 1 次(500ms 延迟)
+     */
+    private suspend fun synthesizeSingleCueWithRetry(
+        routedVoice: ReadAloudVoice,
+        cue: io.legado.app.domain.model.readaloud.ReadAloudPlaybackCue?,
+        content: String,
+        textChapter: TextChapter?,
+        httpTts: HttpTTS,
+    ): Boolean {
+        if (synthesizeSingleCue(routedVoice, cue, content, textChapter, httpTts)) {
+            return true
+        }
+        delay(500)
+        return synthesizeSingleCue(routedVoice, cue, content, textChapter, httpTts)
+    }
+
+    /**
+     * 单个 cue 合成核心方法
+     */
+    private suspend fun synthesizeSingleCue(
+        routedVoice: ReadAloudVoice,
+        cue: io.legado.app.domain.model.readaloud.ReadAloudPlaybackCue?,
+        content: String,
+        textChapter: TextChapter?,
+        httpTts: HttpTTS,
+    ): Boolean {
+        val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
+        val fileName = md5SpeakFileName(content, textChapter, sourceKey = sourceKey)
+        val speakText = content.replace(AppPattern.notReadAloudRegex, "")
+        if (speakText.isEmpty()) {
+            createSilentSound(fileName)
+            return true
+        }
+        val success = runCatching {
+            when (routedVoice.engineType) {
+                ReadAloudVoice.ENGINE_CLOUD -> {
+                    val output = getSpeakFileAsMd5(fileName)
+                    cloudTtsAudioSynthesizer.synthesize(
+                        routedVoice, speakText, output,
+                        styleOverride = cue?.emotion.orEmpty(),
+                        characterPerformance = cue?.characterPerformance,
+                        roleType = cue?.roleType ?: SpeechRoleType.Unknown,
+                    )
+                }
+
+                ReadAloudVoice.ENGINE_HTTP -> {
+                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                        ?.let(appDb.httpTTSDao::get) ?: httpTts
+                    val inputStream = getSpeakStream(itemHttpTts, speakText)
+                    if (inputStream != null) {
+                        createSpeakFile(fileName, inputStream)
+                        true
+                    } else {
+                        false
+                    }
+                }
+
+                else -> false
+            }
+        }.getOrElse {
+            when (it) {
+                is CancellationException -> throw it
+                else -> {
+                    AppLog.put("TTS预合成cue失败: ${it.localizedMessage}")
+                    false
+                }
+            }
+        }
+        if (success && speakText.isNotEmpty()) {
+            writeTextIndexEntry(fileName, speakText)
+        }
+        return success
+    }
+
+    /**
+     * 将合成的文件名和对应文字写入索引,供缓存管理界面显示。
+     */
+    private fun writeTextIndexEntry(fileName: String, text: String) {
+        try {
+            val baseDir = externalCacheDir ?: cacheDir
+            val indexFile = File(baseDir, "httpTTS/tts_cache_index.json")
+            val index = mutableMapOf<String, String>()
+            if (indexFile.exists()) {
+                val json = indexFile.readText()
+                val regex = Regex("\"([^\"]+)\":\"([^\"]*)\"")
+                regex.findAll(json).forEach { match ->
+                    index[match.groupValues[1]] = match.groupValues[2]
+                        .replace("\\n", "\n")
+                        .replace("\\\"", "\"")
+                        .replace("\\\\", "\\")
+                }
+            }
+            val shortText = if (text.length > 200) text.substring(0, 200) + "…" else text
+            index[fileName] = shortText
+            // 限制索引大小,最多保留 2000 条
+            if (index.size > 2000) {
+                val keys = index.keys.toList().takeLast(2000)
+                val trimmed = linkedMapOf<String, String>()
+                keys.forEach { trimmed[it] = index[it]!! }
+                indexFile.writeText(buildIndexJson(trimmed))
+            } else {
+                indexFile.writeText(buildIndexJson(index))
+            }
+        } catch (_: Exception) {
         }
     }
 
+    private fun buildIndexJson(index: Map<String, String>): String = buildString {
+        append("{")
+        index.entries.forEachIndexed { i, (key, value) ->
+            if (i > 0) append(",")
+            val escaped = value
+                .replace("\\", "\\\\")
+                .replace("\"", "\\\"")
+                .replace("\n", "\\n")
+            append("\"$key\":\"$escaped\"")
+        }
+        append("}")
+    }
+
     private fun downloadAndPlayAudiosStream() {
         exoPlayer.clearMediaItems()
         downloadTask?.cancel()
+        preDownloadJob?.cancel()
         downloadTask = execute {
             downloadTaskActiveLock.withLock {
                 ensureActive()
@@ -445,6 +604,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                         downloader.download(null)
                     }
                 }
+                var preDownloadLaunched = false
                 contentList.forEachIndexed { index, content ->
                     ensureActive()
                     if (index < nowSpeak) return@forEachIndexed
@@ -469,6 +629,10 @@ class HttpReadAloudService : BaseReadAloudService(),
                                 if (!pause) {
                                     exoPlayer.prepare()
                                 }
+                                if (!preDownloadLaunched) {
+                                    preDownloadLaunched = true
+                                    launchPreDownloadStream(httpTts, downloaderChannel)
+                                }
                             }
                         } else {
                             if (exoPlayer.mediaItemCount == 0) {
@@ -476,19 +640,29 @@ class HttpReadAloudService : BaseReadAloudService(),
                                 if (!pause) {
                                     exoPlayer.prepare()
                                 }
+                                if (!preDownloadLaunched) {
+                                    preDownloadLaunched = true
+                                    launchPreDownloadStream(httpTts, downloaderChannel)
+                                }
                             } else {
                                 exoPlayer.addMediaSource(mediaSource)
                             }
                         }
                     }
                 }
-                preDownloadAudiosStream(httpTts, downloaderChannel)
             }
         }.onError {
             AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
         }
     }
 
+    private fun launchPreDownloadStream(httpTts: HttpTTS, downloaderChannel: Channel<Downloader>) {
+        preDownloadJob?.cancel()
+        preDownloadJob = lifecycleScope.launch {
+            preDownloadAudiosStream(httpTts, downloaderChannel)
+        }
+    }
+
     private suspend fun preDownloadAudiosStream(
         httpTts: HttpTTS,
         downloaderChannel: Channel<Downloader>
@@ -496,40 +670,83 @@ class HttpReadAloudService : BaseReadAloudService(),
         val book = ReadBook.book ?: return
         val currentIdx = ReadBook.durChapterIndex
         val limit = readAloudSettings.audioPreDownloadNum
-        
+        val concurrency = readAloudSettings.ttsPreSynthesisConcurrency.coerceIn(1, 8)
+        var consecutiveFailures = 0
+
         try {
             for (i in 1..limit) {
                 currentCoroutineContext().ensureActive()
+                if (consecutiveFailures >= 3) {
+                    AppLog.put("TTS流式预合成连续失败${consecutiveFailures}章,已停止")
+                    break
+                }
                 val targetIndex = currentIdx + i
                 val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
-                
                 val prepared = getPreDownloadChapter(book, chapter) ?: continue
-                
-                prepared.contentList.forEachIndexed { index, content ->
-                    currentCoroutineContext().ensureActive()
-                    val routedVoice = voiceForCue(prepared.queue, index, httpTts)
-                    if (routedVoice.engineType != ReadAloudVoice.ENGINE_HTTP) {
-                        return@forEachIndexed
-                    }
-                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
-                        ?.let(appDb.httpTTSDao::get) ?: httpTts
-                    val fileName = md5SpeakFileName(
-                        content,
-                        prepared.textChapter,
-                        itemHttpTts,
-                    )
-                    
-                    val speakText = content.replace(AppPattern.notReadAloudRegex, "")
-                    val dataSourceFactory = createDataSourceFactory(itemHttpTts, speakText)
-                    val downloader = createDownloader(dataSourceFactory, fileName)
-                    downloaderChannel.send(downloader)
-                }
+                val chapterFailed = synthesizeChapterCuesStream(
+                    prepared, httpTts, concurrency, downloaderChannel,
+                )
+                consecutiveFailures = if (chapterFailed) consecutiveFailures + 1 else 0
             }
         } catch (e: Exception) {
             AppLog.put("听书流式预下载异常: ${e.localizedMessage}", e)
         }
     }
 
+    /**
+     * 流式模式下并行预合成一个章节的 cue
+     */
+    private suspend fun synthesizeChapterCuesStream(
+        prepared: PreDownloadChapter,
+        httpTts: HttpTTS,
+        concurrency: Int,
+        downloaderChannel: Channel<Downloader>,
+    ): Boolean = coroutineScope {
+        val semaphore = kotlinx.coroutines.sync.Semaphore(concurrency)
+        var failedCount = 0
+        val totalCues = prepared.contentList.size
+
+        prepared.contentList.mapIndexed { index, content ->
+            async {
+                semaphore.acquire()
+                try {
+                    val routedVoice = voiceForCue(prepared.queue, index, httpTts)
+                    if (routedVoice.engineType == ReadAloudVoice.ENGINE_SYSTEM) {
+                        return@async
+                    }
+                    val cue = prepared.queue.cues.getOrNull(index)
+                    if (routedVoice.engineType == ReadAloudVoice.ENGINE_CLOUD) {
+                        val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
+                        val fileName = md5SpeakFileName(
+                            content, prepared.textChapter, sourceKey = sourceKey,
+                        )
+                        if (hasSpeakFile(fileName)) return@async
+                        val success = synthesizeSingleCueWithRetry(
+                            routedVoice, cue, content, prepared.textChapter, httpTts,
+                        )
+                        if (!success) {
+                            createSilentSound(fileName)
+                            failedCount++
+                        }
+                    } else {
+                        val speakText = content.replace(AppPattern.notReadAloudRegex, "")
+                        val sourceKey = sourceKeyForCue(routedVoice, cue, httpTts)
+                        val fileName = md5SpeakFileName(
+                            content, prepared.textChapter, sourceKey = sourceKey,
+                        )
+                        val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
+                        val downloader = createDownloader(dataSourceFactory, fileName)
+                        downloaderChannel.send(downloader)
+                    }
+                } finally {
+                    semaphore.release()
+                }
+            }
+        }.awaitAll()
+
+        failedCount > totalCues / 2
+    }
+
     private fun createDataSourceFactory(
         httpTts: HttpTTS,
         speakText: String
@@ -675,6 +892,38 @@ class HttpReadAloudService : BaseReadAloudService(),
             )
     }
 
+    /**
+     * 根据引擎类型生成用于文件名和缓存的 sourceKey
+     */
+    private fun sourceKeyForCue(
+        routedVoice: ReadAloudVoice,
+        cue: io.legado.app.domain.model.readaloud.ReadAloudPlaybackCue?,
+        httpTts: HttpTTS,
+    ): String {
+        val cueEmotion = cue?.emotion.orEmpty()
+        val characterPerformance = cue?.characterPerformance
+        val cueRoleType = cue?.roleType ?: SpeechRoleType.Unknown
+        return when (routedVoice.engineType) {
+            ReadAloudVoice.ENGINE_SYSTEM ->
+                "system:${routedVoice.id}:${routedVoice.revision}:${routedVoice.engineId}:${routedVoice.speakerId}"
+
+            ReadAloudVoice.ENGINE_CLOUD ->
+                "cloud:${routedVoice.id}:${routedVoice.revision}:" +
+                        "${CloudTtsEmotionMapper.VERSION}:$cueEmotion:" +
+                        "${CharacterPerformanceInstructionBuilder.VERSION}:" +
+                        "${characterPerformance?.characterId.orEmpty()}:" +
+                        "${characterPerformance?.updatedAt.orZero()}:" +
+                        "${CloudTtsRoleInstructionMapper.VERSION}:" +
+                        cueRoleType.storageValue
+
+            else -> {
+                val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                    ?.let(appDb.httpTTSDao::get) ?: httpTts
+                itemHttpTts.url
+            }
+        }
+    }
+
     private fun voiceForCue(
         queue: ReadAloudPlaybackQueue,
         index: Int,
@@ -833,6 +1082,7 @@ class HttpReadAloudService : BaseReadAloudService(),
      */
     override fun upSpeechRate(reset: Boolean) {
         downloadTask?.cancel()
+        preDownloadJob?.cancel()
         exoPlayer.stop()
         speechRate = speechRatePlay + 5
         if (readAloudSettings.streamReadAloudAudio) {

+ 203 - 42
app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt

@@ -1,28 +1,45 @@
 package io.legado.app.ui.book.read
 
+import androidx.compose.animation.AnimatedContent
 import androidx.compose.animation.core.Animatable
 import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.animateDpAsState
 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.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.offset
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.ExpandLess
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
 import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
@@ -33,17 +50,17 @@ import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.data.entities.Book
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.series.MediumPlainButton
-import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
-import androidx.compose.material3.CircularProgressIndicator
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
+import top.yukonga.miuix.kmp.basic.VerticalDivider
+import kotlin.time.Duration.Companion.milliseconds
 
 @Composable
 fun ReadAloudCapsule(
@@ -52,9 +69,11 @@ fun ReadAloudCapsule(
     offsetXDp: Float,
     offsetYDp: Float,
     progress: Float,
+    autoCollapse: Boolean,
     onPositionChanged: (xDp: Float, yDp: Float) -> Unit,
     onTogglePause: () -> Unit,
     onStop: () -> Unit,
+    onOpenPlayer: () -> Unit,
 ) {
     val density = LocalDensity.current
     val currentOnPositionChanged by rememberUpdatedState(onPositionChanged)
@@ -80,6 +99,37 @@ fun ReadAloudCapsule(
         offsetY = with(density) { Dp(offsetYDp).toPx() }
     }
 
+    var collapsed by remember { mutableStateOf(false) }
+    var touchTimestamp by remember { mutableFloatStateOf(0f) }
+
+    // Auto-collapse timer
+    LaunchedEffect(autoCollapse, collapsed) {
+        if (autoCollapse && !collapsed) {
+            delay(3000.milliseconds)
+            collapsed = true
+        }
+    }
+
+    // Reset collapse on any touch
+    fun onTouched() {
+        touchTimestamp = System.currentTimeMillis().toFloat()
+        if (collapsed) {
+            collapsed = false
+        }
+    }
+
+    val capsuleHeight by animateDpAsState(
+        targetValue = if (collapsed) 16.dp else 48.dp,
+        animationSpec = tween(durationMillis = 250),
+        label = "capsuleHeight",
+    )
+
+    val cornerRadius by animateDpAsState(
+        targetValue = if (collapsed) 8.dp else 28.dp,
+        animationSpec = tween(durationMillis = 250),
+        label = "cornerRadius",
+    )
+
     Box(
         modifier = Modifier.fillMaxSize(),
     ) {
@@ -101,54 +151,165 @@ fun ReadAloudCapsule(
                         change.consume()
                         offsetX += dragAmount.x
                         offsetY += dragAmount.y
+                        onTouched()
                     }
+                }
+                .pointerInput(Unit) {
+                    detectTapGestures(
+                        onTap = { onTouched() },
+                    )
                 },
-            shape = RoundedCornerShape(28.dp),
+            shape = RoundedCornerShape(cornerRadius),
             color = LegadoTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.94f),
             tonalElevation = 6.dp,
             shadowElevation = 8.dp,
         ) {
-            Row(
-                modifier = Modifier.padding(all = 4.dp),
-                horizontalArrangement = Arrangement.spacedBy(4.dp),
-                verticalAlignment = Alignment.CenterVertically,
-            ) {
-                BookCoverImage(
-                    name = book?.name,
-                    author = book?.author,
-                    path = book?.getDisplayCover(),
-                    sourceOrigin = book?.origin,
-                    modifier = Modifier.size(40.dp).graphicsLayer {
-                        rotationZ = coverRotation.value
-                    }.clip(CircleShape),
-                )
-                MediumTonalButton(
-                    onClick = onTogglePause,
-                    modifier = Modifier.size(40.dp),
-                    icon = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
-                    contentDescription = stringResource(
-                        if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
-                    ),
-                )
-                Box(
-                    modifier = Modifier
-                        .size(40.dp),
-                    contentAlignment = Alignment.Center,
-                ) {
-                    CircularProgressIndicator(
-                        modifier = Modifier.fillMaxSize(),
-                        progress = { progress.coerceIn(0f, 1f) },
-                        strokeWidth = 2.dp,
+            AnimatedContent(
+                targetState = collapsed,
+                transitionSpec = {
+                    fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.9f) togetherWith
+                            fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.9f)
+                },
+                label = "capsuleContent",
+            ) { isCollapsed ->
+                if (isCollapsed) {
+                    CollapsedCapsuleContent(
+                        isPaused = isPaused,
+                        onTogglePause = onTogglePause,
+                        onExpand = { collapsed = false },
                     )
-                    MediumPlainButton(
-                        onClick = onStop,
-                        modifier = Modifier.size(40.dp),
-                        icon = Icons.Default.Close,
-                        contentDescription = stringResource(R.string.stop_read_aloud),
-                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                } else {
+                    ExpandedCapsuleContent(
+                        book = book,
+                        isPaused = isPaused,
+                        progress = progress,
+                        coverRotation = coverRotation.value,
+                        onTogglePause = onTogglePause,
+                        onStop = onStop,
+                        onOpenPlayer = onOpenPlayer,
                     )
                 }
             }
         }
     }
 }
+
+@Composable
+private fun CollapsedCapsuleContent(
+    isPaused: Boolean,
+    onTogglePause: () -> Unit,
+    onExpand: () -> Unit,
+) {
+    Row(
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.Center,
+    ) {
+        Row(
+            modifier = Modifier
+                .clickable(onClick = onTogglePause)
+                .padding(all = 8.dp),
+        ) {
+            Icon(
+                imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+                contentDescription = stringResource(
+                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
+                ),
+                modifier = Modifier
+                    .size(16.dp),
+                tint = LegadoTheme.colorScheme.onSurface,
+            )
+            Text(
+                text = stringResource(
+                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
+                ),
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.padding(start = 2.dp),
+            )
+        }
+
+        VerticalDivider(
+            modifier = Modifier
+                .padding(start = 4.dp)
+                .height(10.dp)
+                .width(1.dp),
+            color = LegadoTheme.colorScheme.outlineVariant,
+        )
+
+        Icon(
+            imageVector = Icons.Default.ExpandLess,
+            contentDescription = stringResource(R.string.expand),
+            modifier = Modifier
+                .clickable(onClick = onExpand)
+                .padding(horizontal = 8.dp, vertical = 6.dp)
+                .size(16.dp),
+            tint = LegadoTheme.colorScheme.onSurface,
+        )
+    }
+}
+
+@Composable
+private fun ExpandedCapsuleContent(
+    book: Book?,
+    isPaused: Boolean,
+    progress: Float,
+    coverRotation: Float,
+    onTogglePause: () -> Unit,
+    onStop: () -> Unit,
+    onOpenPlayer: () -> Unit,
+) {
+    Row(
+        modifier = Modifier.padding(all = 4.dp),
+        horizontalArrangement = Arrangement.spacedBy(4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        BookCoverImage(
+            name = book?.name,
+            author = book?.author,
+            path = book?.getDisplayCover(),
+            sourceOrigin = book?.origin,
+            modifier = Modifier
+                .size(40.dp)
+                .graphicsLayer { rotationZ = coverRotation }
+                .clip(CircleShape)
+                .clickable(onClick = onOpenPlayer),
+        )
+        Box(
+            modifier = Modifier
+                .size(40.dp)
+                .clip(CircleShape)
+                .background(LegadoTheme.colorScheme.secondaryContainer)
+                .clickable(onClick = onTogglePause),
+            contentAlignment = Alignment.Center,
+        ) {
+            Icon(
+                imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+                contentDescription = stringResource(
+                    if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
+                ),
+                modifier = Modifier.size(24.dp),
+                tint = LegadoTheme.colorScheme.onSecondaryContainer,
+            )
+        }
+        Box(
+            modifier = Modifier
+                .size(40.dp),
+            contentAlignment = Alignment.Center,
+        ) {
+            CircularProgressIndicator(
+                modifier = Modifier.fillMaxSize(),
+                progress = { progress.coerceIn(0f, 1f) },
+                strokeWidth = 2.dp,
+            )
+            Icon(
+                imageVector = Icons.Default.Close,
+                contentDescription = stringResource(R.string.stop_read_aloud),
+                modifier = Modifier
+                    .size(24.dp)
+                    .clip(CircleShape)
+                    .clickable(onClick = onStop),
+                tint = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
+    }
+}

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -298,12 +298,14 @@ data class ReadBookUiState(
     val editingHttpTts: HttpTTS? = null,
     val httpTtsImportState: BaseImportUiState<HttpTTS> = BaseImportUiState.Idle,
     val preDownloadNum: Int = 10,
+    val preSynthesisConcurrency: Int = 3,
     val audioCacheCleanTime: Int = 10,
     // Read aloud config
     val readAloudIgnoreAudioFocus: Boolean = false,
     val readAloudPauseOnPhoneCall: Boolean = false,
     val readAloudWakeLock: Boolean = false,
     val showReadAloudCapsule: Boolean = true,
+    val capsuleAutoCollapse: Boolean = true,
     val readAloudCapsuleOffsetX: Float = 0f,
     val readAloudCapsuleOffsetY: Float = 0f,
     val readAloudMediaButtonPerNext: Boolean = false,
@@ -715,10 +717,12 @@ sealed interface ReadBookIntent {
     data object ShowReadAloudConfig : ReadBookIntent
     data object SelectSpeakEngine : ReadBookIntent
     data object OpenPreDownloadNumPicker : ReadBookIntent
+    data object OpenPreSynthesisConcurrencyPicker : ReadBookIntent
     data object OpenParagraphIntervalPicker : ReadBookIntent
     data object OpenCacheCleanTimePicker : ReadBookIntent
     data class ApplySpeakEngine(val value: String?) : ReadBookIntent
     data class ApplyPreDownloadNum(val value: Int) : ReadBookIntent
+    data class ApplyPreSynthesisConcurrency(val value: Int) : ReadBookIntent
     data class ApplyAudioCacheCleanTime(val value: Int) : ReadBookIntent
     data class ApplyParagraphInterval(val value: Int) : ReadBookIntent
     data class EditHttpTts(val engineId: Long? = null) : ReadBookIntent
@@ -742,6 +746,7 @@ sealed interface ReadBookIntent {
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
     data class SetShowReadAloudCapsule(val value: Boolean) : ReadBookIntent
+    data class SetCapsuleAutoCollapse(val value: Boolean) : ReadBookIntent
     data object ResetReadAloudCapsulePosition : ReadBookIntent
     data class SetReadAloudCapsulePosition(val x: Float, val y: Float) : ReadBookIntent
     data class SetReadAloudMediaButtonPerNext(val value: Boolean) : ReadBookIntent
@@ -763,6 +768,7 @@ sealed interface ReadBookIntent {
     data object OpenSystemTtsSettings : ReadBookIntent
     data object ClearTtsCache : ReadBookIntent
     data object OpenTtsEnginesAndVoices : ReadBookIntent
+    data object OpenTtsCache : ReadBookIntent
     data object OpenBookVoiceCasting : ReadBookIntent
     data object OpenReadAloudPlayer : ReadBookIntent
     data object OpenClassicReadAloudControls : ReadBookIntent
@@ -907,6 +913,7 @@ sealed interface ReadBookEffect {
     data object OpenHttpTtsExportPicker : ReadBookEffect
     data class OpenHttpTtsLogin(val engineId: Long) : ReadBookEffect
     data object OpenTtsEnginesAndVoices : ReadBookEffect
+    data object OpenTtsCache : ReadBookEffect
     data class OpenBookVoiceCasting(val bookUrl: String) : ReadBookEffect
     data object OpenHighlightRuleImportPicker : ReadBookEffect
     data object OpenHighlightRuleExportPicker : ReadBookEffect
@@ -963,6 +970,7 @@ sealed interface ReadBookSheet {
     data object SpeakEngineConfig : ReadBookSheet
     data class HttpTtsEdit(val engineId: Long? = null) : ReadBookSheet
     data object PreDownloadConfig : ReadBookSheet
+    data object PreSynthesisConcurrencyConfig : ReadBookSheet
     data object AudioCacheCleanConfig : ReadBookSheet
     data object ParagraphIntervalConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet

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

@@ -1064,6 +1064,7 @@ class ReadBookController(
             is ReadBookEffect.OpenHttpTtsExportPicker,
             is ReadBookEffect.OpenHttpTtsLogin,
             ReadBookEffect.OpenTtsEnginesAndVoices,
+            ReadBookEffect.OpenTtsCache,
             is ReadBookEffect.OpenBookVoiceCasting,
             is ReadBookEffect.OpenHighlightRuleImportPicker,
             is ReadBookEffect.OpenHighlightRuleExportPicker,

+ 11 - 7
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -1,11 +1,5 @@
 package io.legado.app.ui.book.read
 
-import androidx.compose.animation.AnimatedVisibility
-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 android.content.Context
 import android.content.Intent
 import android.hardware.Sensor
@@ -19,6 +13,12 @@ import android.widget.ImageView
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.animation.AnimatedVisibility
+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.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
@@ -127,6 +127,7 @@ fun ReadBookRouteScreen(
     onOpenSearch: (word: String?, bookUrl: String) -> Unit = { _, _ -> },
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
+    onOpenTtsCache: () -> Unit = {},
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
@@ -371,6 +372,7 @@ fun ReadBookRouteScreen(
                                 onOpenVoiceCasting(effect.bookUrl)
                             }
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
+                            ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
                             is ReadBookEffect.MenuSettingReplace -> {
                                 replaceLauncher.launch(Intent(context, ReplaceRuleActivity::class.java))
                             }
@@ -547,7 +549,7 @@ fun ReadBookRouteScreen(
             AnimatedVisibility(
                 visible = state.isReadAloudRunning &&
                     state.showReadAloudCapsule &&
-                    state.menuVisible,
+                        !state.menuVisible,
                 enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),
                 exit = fadeOut(tween(140)) + scaleOut(tween(180), targetScale = 0.88f),
             ) {
@@ -558,6 +560,7 @@ fun ReadBookRouteScreen(
                     offsetYDp = state.readAloudCapsuleOffsetY,
                     progress = state.readAloudChapterPosition.toFloat() /
                         state.readAloudChapterLength.coerceAtLeast(1),
+                    autoCollapse = state.capsuleAutoCollapse,
                     onPositionChanged = { x, y ->
                         viewModel.onIntent(ReadBookIntent.SetReadAloudCapsulePosition(x, y))
                     },
@@ -565,6 +568,7 @@ fun ReadBookRouteScreen(
                         viewModel.onIntent(ReadBookIntent.ReadAloudTogglePause)
                     },
                     onStop = { viewModel.onIntent(ReadBookIntent.ReadAloudStop) },
+                    onOpenPlayer = { viewModel.onIntent(ReadBookIntent.OpenReadAloudPlayer) },
                 )
             }
             ReadBookScreen(

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

@@ -11,8 +11,10 @@ import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.ImageLoader
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
+import io.legado.app.domain.gateway.CoverSettingsGateway
 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,23 +45,21 @@ 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.dict.DictSheet
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.ProvideThemeOverride
+import io.legado.app.ui.theme.rememberImageSeedColor
+import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
+import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.log.AppLogSheet
 import io.legado.app.utils.toastOnUi
-import coil.ImageLoader
-import io.legado.app.domain.gateway.CoverSettingsGateway
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ProvideThemeOverride
-import io.legado.app.ui.theme.rememberImageSeedColor
-import io.legado.app.ui.theme.rememberThemeOverride
-import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
-import io.legado.app.model.BookCover as BookCoverModel
 import kotlinx.coroutines.flow.collectLatest
 import org.koin.compose.koinInject
+import io.legado.app.model.BookCover as BookCoverModel
 
 /**
  * Stateless ReadBook screen — renders BackHandler + dialogs + sheets.
@@ -332,6 +332,20 @@ fun ReadBookScreen(
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
         },
     )
+    ReadAloudNumberConfigSheet(
+        show = state.activeSheet is ReadBookSheet.PreSynthesisConcurrencyConfig,
+        title = stringResource(R.string.tts_pre_synthesis_concurrency),
+        description = stringResource(
+            R.string.tts_pre_synthesis_concurrency_summary, state.preSynthesisConcurrency,
+        ),
+        value = state.preSynthesisConcurrency,
+        defaultValue = 3,
+        valueRange = 1f..8f,
+        onValueChange = { onIntent(ReadBookIntent.ApplyPreSynthesisConcurrency(it)) },
+        onDismissRequest = {
+            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
+        },
+    )
     ReadAloudNumberConfigSheet(
         show = state.activeSheet is ReadBookSheet.AudioCacheCleanConfig,
         title = stringResource(R.string.audio_cache_clean_time),

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

@@ -48,6 +48,7 @@ import io.legado.app.domain.gateway.ChangeSourceSettingsGateway
 import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsUpdate
+import io.legado.app.domain.gateway.ReadSettingsUpdate
 import io.legado.app.domain.gateway.ReadStyleBooleanKey
 import io.legado.app.domain.gateway.ReadStyleColorKey
 import io.legado.app.domain.gateway.ReadStyleFloatKey
@@ -55,12 +56,11 @@ import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ReadStyleIntKey
 import io.legado.app.domain.gateway.ReadStyleMutation
 import io.legado.app.domain.gateway.ReadStyleStringKey
-import io.legado.app.domain.gateway.ReadSettingsUpdate
-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.TextProcessAction
+import io.legado.app.domain.model.TextProcessAnchor
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
@@ -966,6 +966,16 @@ class ReadBookViewModel(
                 }
             }
 
+            is ReadBookIntent.OpenPreSynthesisConcurrencyPicker -> {
+                _uiState.update {
+                    it.copy(
+                        preSynthesisConcurrency =
+                            readAloudSettingsRepository.currentSettings.ttsPreSynthesisConcurrency,
+                        activeSheet = ReadBookSheet.PreSynthesisConcurrencyConfig,
+                    )
+                }
+            }
+
             is ReadBookIntent.OpenParagraphIntervalPicker -> {
                 _uiState.update {
                     it.copy(
@@ -1013,6 +1023,18 @@ class ReadBookViewModel(
                 }
             }
 
+            is ReadBookIntent.ApplyPreSynthesisConcurrency -> {
+                viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
+                    readAloudSettingsRepository.setPreSynthesisConcurrency(intent.value)
+                }
+                _uiState.update {
+                    it.copy(
+                        preSynthesisConcurrency = intent.value,
+                        activeSheet = ReadBookSheet.ReadAloudConfig,
+                    )
+                }
+            }
+
             is ReadBookIntent.ApplyAudioCacheCleanTime -> {
                 viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) {
                     readAloudSettingsRepository.setAudioCacheCleanTime(intent.value)
@@ -1183,6 +1205,9 @@ class ReadBookViewModel(
             is ReadBookIntent.SetShowReadAloudCapsule -> {
                 viewModelScope.launch { readAloudSettingsRepository.setShowReadAloudCapsule(intent.value) }
             }
+            is ReadBookIntent.SetCapsuleAutoCollapse -> {
+                viewModelScope.launch { readAloudSettingsRepository.setCapsuleAutoCollapse(intent.value) }
+            }
             ReadBookIntent.ResetReadAloudCapsulePosition -> {
                 _uiState.update { it.copy(
                     readAloudCapsuleOffsetX = 0f,
@@ -1294,6 +1319,10 @@ class ReadBookViewModel(
                 _uiState.update { it.copy(activeSheet = null) }
                 _effects.tryEmit(ReadBookEffect.OpenTtsEnginesAndVoices)
             }
+            ReadBookIntent.OpenTtsCache -> {
+                _uiState.update { it.copy(activeSheet = null) }
+                _effects.tryEmit(ReadBookEffect.OpenTtsCache)
+            }
             ReadBookIntent.OpenBookVoiceCasting -> {
                 ReadBook.book?.bookUrl?.let { bookUrl ->
                     _uiState.update { it.copy(activeSheet = null) }
@@ -2170,6 +2199,7 @@ class ReadBookViewModel(
                         readAloudPauseOnPhoneCall = prefs.pauseReadAloudWhilePhoneCalls,
                         readAloudWakeLock = prefs.readAloudWakeLock,
                         showReadAloudCapsule = prefs.showReadAloudCapsule,
+                        capsuleAutoCollapse = prefs.capsuleAutoCollapse,
                         readAloudCapsuleOffsetX = prefs.capsuleOffsetX,
                         readAloudCapsuleOffsetY = prefs.capsuleOffsetY,
                         readAloudMediaButtonPerNext = prefs.mediaButtonPerNext,

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

@@ -118,6 +118,16 @@ fun ReadAloudConfigSheet(
                     onIntent(ReadBookIntent.SetShowReadAloudCapsule(it))
                 },
             )
+                if (state.showReadAloudCapsule) {
+                    TinySwitchSettingItem(
+                        title = stringResource(R.string.capsule_auto_collapse),
+                        description = stringResource(R.string.capsule_auto_collapse_summary),
+                        checked = state.capsuleAutoCollapse,
+                        onCheckedChange = {
+                            onIntent(ReadBookIntent.SetCapsuleAutoCollapse(it))
+                        },
+                    )
+                }
             TinySwitchSettingItem(
                 title = stringResource(R.string.ignore_audio_focus_title),
                 description = stringResource(R.string.ignore_audio_focus_summary),
@@ -194,6 +204,11 @@ fun ReadAloudConfigSheet(
                 description = stringResource(R.string.read_aloud_engines_and_voices_summary),
                 onClick = { onIntent(ReadBookIntent.OpenTtsEnginesAndVoices) },
             )
+                TinyClickableSettingItem(
+                    title = stringResource(R.string.tts_cache_manage),
+                    description = stringResource(R.string.tts_cache_manage_summary),
+                    onClick = { onIntent(ReadBookIntent.OpenTtsCache) },
+                )
             TinyClickableSettingItem(
                 title = stringResource(R.string.read_aloud_character_casting),
                 description = stringResource(R.string.book_voice_casting_entry_summary),
@@ -231,6 +246,10 @@ fun ReadAloudConfigSheet(
                 title = stringResource(R.string.read_aloud_preload),
                 onClick = { onIntent(ReadBookIntent.OpenPreDownloadNumPicker) },
             )
+                TinyClickableSettingItem(
+                    title = stringResource(R.string.tts_pre_synthesis_concurrency),
+                    onClick = { onIntent(ReadBookIntent.OpenPreSynthesisConcurrencyPicker) },
+                )
             TinyClickableSettingItem(
                 title = stringResource(R.string.tts_paragraph_interval),
                 onClick = { onIntent(ReadBookIntent.OpenParagraphIntervalPicker) },

+ 63 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheContract.kt

@@ -0,0 +1,63 @@
+package io.legado.app.ui.book.readaloud.cache
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class TtsCacheTab { Files, Logs }
+
+@Stable
+data class TtsCacheUiState(
+    val loading: Boolean = true,
+    val files: ImmutableList<TtsCacheFileUi> = persistentListOf(),
+    val totalSizeBytes: Long = 0,
+    val logs: ImmutableList<TtsLogEntryUi> = persistentListOf(),
+    val selectedTab: TtsCacheTab = TtsCacheTab.Files,
+    val activeDialog: TtsCacheDialog? = null,
+    val detailTitle: String = "",
+    val detailContent: String = "",
+    val showDetail: Boolean = false,
+)
+
+@Stable
+data class TtsCacheFileUi(
+    val name: String,
+    val text: String,
+    val sizeBytes: Long,
+    val lastModified: Long,
+)
+
+@Stable
+data class TtsLogEntryUi(
+    val timestamp: Long,
+    val message: String,
+    val hasError: Boolean,
+    val fullContent: String,
+)
+
+sealed interface TtsCacheIntent {
+    data object LoadCache : TtsCacheIntent
+    data object LoadLogs : TtsCacheIntent
+    data class SelectTab(val tab: TtsCacheTab) : TtsCacheIntent
+    data class DeleteFile(val name: String) : TtsCacheIntent
+    data object ClearAll : TtsCacheIntent
+    data object ShowClearAllDialog : TtsCacheIntent
+    data object DismissDialog : TtsCacheIntent
+    data class ShowFileDetail(
+        val name: String,
+        val text: String,
+        val sizeBytes: Long,
+        val lastModified: Long
+    ) : TtsCacheIntent
+
+    data class ShowLogDetail(val timestamp: Long, val fullContent: String) : TtsCacheIntent
+    data object DismissDetail : TtsCacheIntent
+}
+
+sealed interface TtsCacheEffect {
+    data class ShowToast(val message: String) : TtsCacheEffect
+}
+
+sealed interface TtsCacheDialog {
+    data object ClearAll : TtsCacheDialog
+}

+ 193 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheScreen.kt

@@ -0,0 +1,193 @@
+package io.legado.app.ui.book.readaloud.cache
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DeleteSweep
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.log.LogDetailSheet
+import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
+import io.legado.app.ui.widget.components.tabRow.AppTabRow
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun TtsCacheRouteScreen(
+    onBackClick: () -> Unit,
+    viewModel: TtsCacheViewModel = koinViewModel(),
+) {
+    TtsCacheScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        onIntent = viewModel::onIntent,
+        effects = viewModel.effects,
+        onBackClick = onBackClick,
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TtsCacheScreen(
+    state: TtsCacheUiState,
+    onIntent: (TtsCacheIntent) -> Unit,
+    effects: kotlinx.coroutines.flow.Flow<TtsCacheEffect>,
+    onBackClick: () -> Unit,
+) {
+    val context = LocalContext.current
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is TtsCacheEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val dateFormat = remember { SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()) }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.tts_cache_manage),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                },
+                bottomContent = {
+                    AppTabRow(
+                        tabTitles = listOf(
+                            stringResource(R.string.tts_cache_tab_files),
+                            stringResource(R.string.tts_cache_tab_logs),
+                        ),
+                        selectedTabIndex = state.selectedTab.ordinal,
+                        onTabSelected = { onIntent(TtsCacheIntent.SelectTab(TtsCacheTab.entries[it])) },
+                        isScrollable = false,
+                    )
+                }
+            )
+        },
+        floatingActionButton = {
+            if (state.selectedTab == TtsCacheTab.Files && state.files.isNotEmpty()) {
+                AppFloatingActionButton(
+                    onClick = { onIntent(TtsCacheIntent.ShowClearAllDialog) },
+                    icon = Icons.Default.DeleteSweep,
+                    tooltipText = stringResource(R.string.clear_all),
+                )
+            }
+        },
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding() + 8.dp,
+                bottom = paddingValues.calculateBottomPadding() + 96.dp,
+            ),
+        ) {
+            if (state.selectedTab == TtsCacheTab.Files) {
+                item {
+                    val sizeText = TtsCacheViewModel.formatSize(state.totalSizeBytes)
+                    val countText =
+                        context.getString(R.string.tts_cache_file_count, state.files.size)
+                    TinyClickableSettingItem(
+                        title = stringResource(R.string.tts_cache_total),
+                        description = "$sizeText · $countText",
+                        onClick = {},
+                    )
+                }
+                if (state.files.isEmpty() && !state.loading) {
+                    item {
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.tts_cache_empty),
+                            description = stringResource(R.string.tts_cache_empty_summary),
+                            onClick = {},
+                        )
+                    }
+                }
+                items(state.files, key = { it.name }) { file ->
+                    val sizeText = TtsCacheViewModel.formatSize(file.sizeBytes)
+                    val dateText = dateFormat.format(Date(file.lastModified))
+                    val displayText = file.text.ifEmpty {
+                        context.getString(R.string.tts_cache_unknown_text)
+                    }
+                    TinyClickableSettingItem(
+                        title = displayText,
+                        description = "$sizeText · $dateText",
+                        onClick = {
+                            onIntent(
+                                TtsCacheIntent.ShowFileDetail(
+                                    name = file.name,
+                                    text = file.text,
+                                    sizeBytes = file.sizeBytes,
+                                    lastModified = file.lastModified,
+                                )
+                            )
+                        },
+                    )
+                }
+            } else {
+                if (state.logs.isEmpty()) {
+                    item {
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.tts_cache_logs_empty),
+                            description = stringResource(R.string.tts_cache_logs_empty_summary),
+                            onClick = {},
+                        )
+                    }
+                }
+                items(state.logs, key = { "${it.timestamp}:${it.message.hashCode()}" }) { entry ->
+                    val timeText = dateFormat.format(Date(entry.timestamp))
+                    TinyClickableSettingItem(
+                        title = timeText,
+                        description = entry.message,
+                        onClick = {
+                            onIntent(
+                                TtsCacheIntent.ShowLogDetail(
+                                    timestamp = entry.timestamp,
+                                    fullContent = entry.fullContent,
+                                )
+                            )
+                        },
+                    )
+                }
+            }
+        }
+    }
+
+    AppAlertDialog(
+        show = state.activeDialog == TtsCacheDialog.ClearAll,
+        onDismissRequest = { onIntent(TtsCacheIntent.DismissDialog) },
+        title = stringResource(R.string.clear_all_tts_cache),
+        text = stringResource(R.string.sure_del),
+        onConfirm = { onIntent(TtsCacheIntent.ClearAll) },
+        onDismiss = { onIntent(TtsCacheIntent.DismissDialog) },
+    )
+
+    LogDetailSheet(
+        show = state.showDetail,
+        title = state.detailTitle,
+        content = state.detailContent,
+        onDismissRequest = { onIntent(TtsCacheIntent.DismissDetail) },
+    )
+}

+ 233 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/cache/TtsCacheViewModel.kt

@@ -0,0 +1,233 @@
+package io.legado.app.ui.book.readaloud.cache
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.constant.AppLog
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.Dispatchers
+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.launch
+import splitties.init.appCtx
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class TtsCacheViewModel : ViewModel() {
+
+    private val _uiState = MutableStateFlow(TtsCacheUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<TtsCacheEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    private val ttsCacheDir: File?
+        get() {
+            val baseDir = appCtx.externalCacheDir ?: appCtx.cacheDir
+            return File(baseDir, "httpTTS").takeIf { it.exists() }
+        }
+
+    private val textIndexFile: File?
+        get() = ttsCacheDir?.let { File(it, "tts_cache_index.json") }
+
+    init {
+        loadCache()
+        loadLogs()
+    }
+
+    fun onIntent(intent: TtsCacheIntent) {
+        when (intent) {
+            TtsCacheIntent.LoadCache -> loadCache()
+            TtsCacheIntent.LoadLogs -> loadLogs()
+            is TtsCacheIntent.SelectTab -> _uiState.update { it.copy(selectedTab = intent.tab) }
+            is TtsCacheIntent.DeleteFile -> deleteFile(intent.name)
+            TtsCacheIntent.ClearAll -> clearAll()
+            TtsCacheIntent.ShowClearAllDialog ->
+                _uiState.update { it.copy(activeDialog = TtsCacheDialog.ClearAll) }
+
+            TtsCacheIntent.DismissDialog ->
+                _uiState.update { it.copy(activeDialog = null) }
+
+            is TtsCacheIntent.ShowFileDetail -> _uiState.update {
+                it.copy(
+                    detailTitle = intent.name,
+                    detailContent = buildString {
+                        append("分片文字:\n${intent.text}\n\n")
+                        append("文件大小: ${formatSize(intent.sizeBytes)}\n")
+                        append("创建时间: ${detailDateFormat.format(Date(intent.lastModified))}\n")
+                        append("文件名: ${intent.name}.mp3")
+                    },
+                    showDetail = true,
+                )
+            }
+
+            is TtsCacheIntent.ShowLogDetail -> _uiState.update {
+                it.copy(
+                    detailTitle = detailTimeFormat.format(Date(intent.timestamp)),
+                    detailContent = intent.fullContent,
+                    showDetail = true,
+                )
+            }
+
+            TtsCacheIntent.DismissDetail -> _uiState.update { it.copy(showDetail = false) }
+        }
+    }
+
+    private fun loadCache() {
+        viewModelScope.launch(Dispatchers.IO) {
+            val dir = ttsCacheDir
+            if (dir == null) {
+                _uiState.update {
+                    it.copy(loading = false, files = persistentListOf(), totalSizeBytes = 0)
+                }
+                return@launch
+            }
+            val index = loadTextIndex()
+            val files = dir.listFiles()
+                ?.filter { it.isFile && it.name.endsWith(".mp3") }
+                ?.sortedByDescending { it.lastModified() }
+                ?.map { file ->
+                    val baseName = file.nameWithoutExtension
+                    val text = index[baseName].orEmpty()
+                    TtsCacheFileUi(
+                        name = baseName,
+                        text = text,
+                        sizeBytes = file.length(),
+                        lastModified = file.lastModified(),
+                    )
+                } ?: emptyList()
+            val totalSize = files.sumOf { it.sizeBytes }
+            _uiState.update {
+                it.copy(
+                    loading = false,
+                    files = persistentListOf<TtsCacheFileUi>().let { list ->
+                        list.builder().apply { addAll(files) }.build()
+                    },
+                    totalSizeBytes = totalSize,
+                )
+            }
+        }
+    }
+
+    private fun loadLogs() {
+        viewModelScope.launch(Dispatchers.Default) {
+            val ttsKeywords =
+                listOf("TTS", "预合成", "预下载", "朗读", "听书", "httpTTS", "朗读下载")
+            val logs = AppLog.logs
+                .filter { (_, message, _) ->
+                    ttsKeywords.any { keyword -> message.contains(keyword, ignoreCase = true) }
+                }
+                .map { (timestamp, message, throwable) ->
+                    val fullContent = if (throwable != null) {
+                        "$message\n${throwable.stackTraceToString()}"
+                    } else {
+                        message
+                    }
+                    TtsLogEntryUi(
+                        timestamp = timestamp,
+                        message = message,
+                        hasError = throwable != null,
+                        fullContent = fullContent,
+                    )
+                }
+            _uiState.update {
+                it.copy(logs = persistentListOf<TtsLogEntryUi>().let { list ->
+                    list.builder().apply { addAll(logs) }.build()
+                })
+            }
+        }
+    }
+
+    private fun loadTextIndex(): Map<String, String> {
+        return try {
+            val file = textIndexFile ?: return emptyMap()
+            if (!file.exists()) return emptyMap()
+            val json = file.readText()
+            val map = mutableMapOf<String, String>()
+            // Simple JSON parsing: {"filename":"text",...}
+            val regex = Regex("\"([^\"]+)\":\"([^\"]*)\"")
+            regex.findAll(json).forEach { match ->
+                map[match.groupValues[1]] = match.groupValues[2]
+                    .replace("\\n", "\n")
+                    .replace("\\\"", "\"")
+                    .replace("\\\\", "\\")
+            }
+            map
+        } catch (e: Exception) {
+            emptyMap()
+        }
+    }
+
+    private fun deleteFile(name: String) {
+        viewModelScope.launch(Dispatchers.IO) {
+            val dir = ttsCacheDir ?: return@launch
+            val file = File(dir, "$name.mp3")
+            if (file.exists() && file.delete()) {
+                removeFromTextIndex(name)
+                _effects.tryEmit(TtsCacheEffect.ShowToast("已删除"))
+                loadCache()
+            }
+        }
+    }
+
+    private fun clearAll() {
+        viewModelScope.launch(Dispatchers.IO) {
+            _uiState.update { it.copy(activeDialog = null) }
+            val dir = ttsCacheDir
+            if (dir != null && dir.exists()) {
+                dir.listFiles()?.forEach { it.delete() }
+            }
+            textIndexFile?.delete()
+            _effects.tryEmit(TtsCacheEffect.ShowToast("缓存已清除"))
+            loadCache()
+        }
+    }
+
+    private fun removeFromTextIndex(filename: String) {
+        try {
+            val file = textIndexFile ?: return
+            if (!file.exists()) return
+            val index = loadTextIndex().toMutableMap()
+            index.remove(filename)
+            writeTextIndex(index)
+        } catch (_: Exception) {
+        }
+    }
+
+    private fun writeTextIndex(index: Map<String, String>) {
+        try {
+            val file = textIndexFile ?: return
+            val json = buildString {
+                append("{")
+                index.entries.forEachIndexed { i, (key, value) ->
+                    if (i > 0) append(",")
+                    val escaped = value
+                        .replace("\\", "\\\\")
+                        .replace("\"", "\\\"")
+                        .replace("\n", "\\n")
+                    append("\"$key\":\"$escaped\"")
+                }
+                append("}")
+            }
+            file.writeText(json)
+        } catch (_: Exception) {
+        }
+    }
+
+    companion object {
+        private val detailDateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+        private val detailTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+
+        fun formatSize(bytes: Long): String {
+            return when {
+                bytes < 1024 -> "$bytes B"
+                bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+                else -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
+            }
+        }
+    }
+}

+ 11 - 12
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt

@@ -95,6 +95,17 @@ fun CloudTtsScreen(
                 title = stringResource(R.string.read_aloud_engines_and_voices),
                 navigationIcon = { TopBarNavigationButton(onClick = onBack) },
                 scrollBehavior = scrollBehavior,
+                bottomContent = {
+                    AppTabRow(
+                        tabTitles = listOf(
+                            stringResource(R.string.cloud_tts_voices_tab),
+                            stringResource(R.string.cloud_tts_engines_tab),
+                        ),
+                        selectedTabIndex = state.selectedTab.ordinal,
+                        onTabSelected = { onIntent(CloudTtsIntent.SelectTab(CloudTtsTab.entries[it])) },
+                        isScrollable = false,
+                    )
+                }
             )
         },
         floatingActionButton = {
@@ -121,18 +132,6 @@ fun CloudTtsScreen(
                 bottom = padding.calculateBottomPadding() + 96.dp,
             ),
         ) {
-            item {
-                AppTabRow(
-                    tabTitles = listOf(
-                        stringResource(R.string.cloud_tts_voices_tab),
-                        stringResource(R.string.cloud_tts_engines_tab),
-                    ),
-                    selectedTabIndex = state.selectedTab.ordinal,
-                    onTabSelected = { onIntent(CloudTtsIntent.SelectTab(CloudTtsTab.entries[it])) },
-                    isScrollable = false,
-                    modifier = Modifier.fillMaxWidth(),
-                )
-            }
             if (state.selectedTab == CloudTtsTab.Voices) {
                 if (state.voices.isEmpty() && !state.loading) {
                     item { AppText(stringResource(R.string.cloud_tts_no_saved_voices), Modifier.padding(24.dp)) }

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

@@ -147,6 +147,8 @@ class ReadAloudPlayerCoordinator(
             EventBus.UP_CONFIG,
             EventBus.UPDATE_READ_ACTION_BAR,
             EventBus.SOURCE_CHANGED,
+            EventBus.ALOUD_STATE,
+            EventBus.TTS_PROGRESS,
         )
     }
 

+ 22 - 12
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -10,8 +10,8 @@ import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.togetherWith
 import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
@@ -43,21 +43,21 @@ import io.legado.app.ui.book.import.remote.RemoteBookRouteScreen
 import io.legado.app.ui.book.info.BookInfoRouteScreen
 import io.legado.app.ui.book.info.BookInfoViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterDetailScreen
-import io.legado.app.ui.book.knowledge.CharacterDetailIntent
-import io.legado.app.ui.book.knowledge.CharacterAvatarCropDialog
 import io.legado.app.ui.book.knowledge.BookCharacterDetailViewModel
-import io.legado.app.ui.book.knowledge.BookCharacterNetworkScreen
-import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
 import io.legado.app.ui.book.knowledge.BookCharacterListScreen
 import io.legado.app.ui.book.knowledge.BookCharacterListViewModel
-import io.legado.app.ui.book.knowledge.BookKnowledgeDetailScreen
-import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
-import io.legado.app.ui.book.knowledge.BookKnowledgeListScreen
-import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
+import io.legado.app.ui.book.knowledge.BookCharacterNetworkScreen
+import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
 import io.legado.app.ui.book.knowledge.BookEventDetailScreen
 import io.legado.app.ui.book.knowledge.BookEventDetailViewModel
 import io.legado.app.ui.book.knowledge.BookEventListScreen
 import io.legado.app.ui.book.knowledge.BookEventListViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeDetailScreen
+import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeListScreen
+import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
+import io.legado.app.ui.book.knowledge.CharacterAvatarCropDialog
+import io.legado.app.ui.book.knowledge.CharacterDetailIntent
 import io.legado.app.ui.book.knowledge.deleteCharacterAvatar
 import io.legado.app.ui.book.knowledge.saveCharacterAvatar
 import io.legado.app.ui.book.manage.BookshelfManageRouteScreen
@@ -65,12 +65,13 @@ import io.legado.app.ui.book.read.ReadBookController
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookRouteScreen
 import io.legado.app.ui.book.read.ReadBookViewModel
+import io.legado.app.ui.book.readRecord.ReadRecordOverviewRouteScreen
+import io.legado.app.ui.book.readRecord.ReadRecordRouteScreen
+import io.legado.app.ui.book.readaloud.cache.TtsCacheRouteScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingViewModel
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsScreen
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
-import io.legado.app.ui.book.readRecord.ReadRecordOverviewRouteScreen
-import io.legado.app.ui.book.readRecord.ReadRecordRouteScreen
 import io.legado.app.ui.book.search.SearchIntent
 import io.legado.app.ui.book.search.SearchRouteScreen
 import io.legado.app.ui.book.search.SearchViewModel
@@ -81,8 +82,8 @@ import io.legado.app.ui.config.ConfigNavScreen
 import io.legado.app.ui.config.ai.AiConfigRouteScreen
 import io.legado.app.ui.config.ai.AiModelEditRouteScreen
 import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
-import io.legado.app.ui.config.ai.summary.AiSummaryConfigRouteScreen
 import io.legado.app.ui.config.ai.prompt.AiPromptConfigRouteScreen
+import io.legado.app.ui.config.ai.summary.AiSummaryConfigRouteScreen
 import io.legado.app.ui.config.backupConfig.BackupConfigRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverAlbumManageRouteScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigRouteScreen
@@ -453,6 +454,9 @@ fun MainActivity.mainEntryProvider(
             onOpenTtsEnginesAndVoices = {
                 onNavigateToRoute(MainRouteCloudTtsEngines)
             },
+            onOpenTtsCache = {
+                onNavigateToRoute(MainRouteTtsCache)
+            },
         )
 
         DisposableEffect(controller, lifecycleOwner, route.readAloud) {
@@ -864,6 +868,12 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
+    entry<MainRouteTtsCache> {
+        TtsCacheRouteScreen(
+            onBackClick = { onNavigateBack() },
+        )
+    }
+
     entry<MainRouteBookKnowledgeList> { route ->
         val viewModel = koinViewModel<BookKnowledgeListViewModel>(
             key = "KnowledgeList:${route.bookUrl}",

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

@@ -134,6 +134,9 @@ data class MainRouteBookVoiceCasting(
 @Serializable
 data object MainRouteCloudTtsEngines : MainRoute
 
+@Serializable
+data object MainRouteTtsCache : MainRoute
+
 @Serializable
 data class MainRouteBookKnowledgeList(
     val bookUrl: String,
@@ -222,4 +225,5 @@ object MainRouteConst {
     const val ROUTE_READ_RECORD = "read_record"
     const val ROUTE_READ_RECORD_OVERVIEW = "read_record_overview"
     const val ROUTE_ABOUT = "about"
+    const val ROUTE_TTS_CACHE = "tts_cache"
 }

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

@@ -127,6 +127,7 @@ object MainNavigator {
             is MainRouteBookCharacterList,
             is MainRouteBookVoiceCasting,
             MainRouteCloudTtsEngines,
+            MainRouteTtsCache,
             is MainRouteBookKnowledgeList,
             is MainRouteBookKnowledgeDetail,
             is MainRouteBookEventList,
@@ -138,6 +139,7 @@ object MainNavigator {
                     currentRoute is MainRouteBookCharacterList ||
                     currentRoute is MainRouteBookVoiceCasting ||
                     currentRoute == MainRouteCloudTtsEngines ||
+                    currentRoute == MainRouteTtsCache ||
                     currentRoute is MainRouteBookKnowledgeList ||
                     currentRoute is MainRouteBookKnowledgeDetail ||
                     currentRoute is MainRouteBookEventList ||

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

@@ -2810,4 +2810,20 @@
     <string name="apply">应用</string>
     <string name="source_categories">书源分类</string>
     <string name="my_tags">我的标签</string>
+    <string name="tts_cache_manage">朗读缓存</string>
+    <string name="tts_cache_total">缓存总量</string>
+    <string name="tts_cache_file_count">%d 个文件</string>
+    <string name="tts_cache_empty">暂无缓存音频</string>
+    <string name="tts_cache_empty_summary">朗读过程中会自动缓存音频到这里</string>
+    <string name="clear_all_tts_cache">清除全部朗读缓存</string>
+    <string name="tts_cache_manage_summary">查看和管理朗读音频缓存文件</string>
+    <string name="tts_pre_synthesis_concurrency">预合成线程数</string>
+    <string name="tts_pre_synthesis_concurrency_summary">%d 线程</string>
+    <string name="tts_cache_tab_files">缓存文件</string>
+    <string name="tts_cache_tab_logs">操作日志</string>
+    <string name="tts_cache_logs_empty">暂无朗读日志</string>
+    <string name="tts_cache_logs_empty_summary">朗读合成和预下载的日志会显示在这里</string>
+    <string name="tts_cache_unknown_text">未知文本</string>
+    <string name="capsule_auto_collapse">自动收缩胶囊</string>
+    <string name="capsule_auto_collapse_summary">3秒无操作后收缩为迷你胶囊</string>
 </resources>

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

@@ -2815,4 +2815,20 @@
     <string name="apply">Apply</string>
     <string name="source_categories">Source categories</string>
     <string name="my_tags">My tags</string>
+    <string name="tts_cache_manage">TTS Cache</string>
+    <string name="tts_cache_total">Total Cache</string>
+    <string name="tts_cache_file_count">%d files</string>
+    <string name="tts_cache_empty">No cached audio</string>
+    <string name="tts_cache_empty_summary">TTS audio will be cached here during playback</string>
+    <string name="clear_all_tts_cache">Clear All TTS Cache</string>
+    <string name="tts_cache_manage_summary">View and manage cached TTS audio files</string>
+    <string name="tts_pre_synthesis_concurrency">Pre-synthesis threads</string>
+    <string name="tts_pre_synthesis_concurrency_summary">%d threads</string>
+    <string name="tts_cache_tab_files">Cached Files</string>
+    <string name="tts_cache_tab_logs">Logs</string>
+    <string name="tts_cache_logs_empty">No TTS logs</string>
+    <string name="tts_cache_logs_empty_summary">TTS synthesis and pre-download logs will appear here</string>
+    <string name="tts_cache_unknown_text">Unknown text</string>
+    <string name="capsule_auto_collapse">Auto-collapse capsule</string>
+    <string name="capsule_auto_collapse_summary">Collapse to mini capsule after 3 seconds of inactivity</string>
 </resources>