From 715e22681dd5cca9d7d30e9f0f84407342396b55 Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:58:50 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E5=A4=84=E7=90=86=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/legado/app/ui/book/toc/TocScreen.kt | 15 +++ .../io/legado/app/ui/book/toc/TocViewModel.kt | 108 ++++++++++++++++-- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt index 3d7e08d1b..1ae590013 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt @@ -106,6 +106,7 @@ import io.legado.app.ui.widget.components.list.TopFloatingStickyItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator +import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator import io.legado.app.ui.widget.components.tabRow.AppTabRow import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar @@ -603,6 +604,20 @@ fun TocScreen( } } + AnimatedVisibility( + visible = isOnTocPage && state.titleReplaceProgress != null, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = padding.calculateTopPadding()) + .fillMaxWidth() + .zIndex(2f) + ) { + AppLinearProgressIndicator( + progress = state.titleReplaceProgress ?: 0f, + modifier = Modifier.fillMaxWidth() + ) + } + TopFloatingStickyItem( item = stickyVolume, modifier = Modifier diff --git a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt index 40249f30e..cc2c209e9 100644 --- a/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/toc/TocViewModel.kt @@ -35,12 +35,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.scan @@ -83,6 +86,7 @@ data class TocActionState( val downloadSummary: String = "", val useReplace: Boolean = false, val showWordCount: Boolean = true, + val titleReplaceProgress: Float? = null, ) : ListUiState data class TocDomainItem( @@ -116,6 +120,14 @@ private data class TitleCacheKey( val chapterCount: Int ) +private data class TitleReplaceState( + val cacheKey: TitleCacheKey? = null, + val titles: Map = emptyMap(), + val completed: Int = 0, + val total: Int = 0, + val isRunning: Boolean = false +) + @OptIn(ExperimentalCoroutinesApi::class) class TocViewModel( application: Application, @@ -240,7 +252,7 @@ class TocViewModel( TocUiConfig(collapsed, tocPreferences.useReplace, tocPreferences.showWordCount, isReverse) } - private val titleReplaceCache = MutableStateFlow>(emptyMap()) + private val titleReplaceState = MutableStateFlow(TitleReplaceState()) private var titleCacheJob: Job? = null private var lastTitleCacheKey: TitleCacheKey? = null @@ -249,8 +261,8 @@ class TocViewModel( .flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) }, downloadContextFlow, uiConfigFlow, - titleReplaceCache - ) { originalChapters, downloadCtx, config, cachedTitles -> + titleReplaceState + ) { originalChapters, downloadCtx, config, titleState -> val book = bookState.value ?: return@combine emptyList() val processedChapters = if (config.isReverse) { @@ -275,7 +287,7 @@ class TocViewModel( val baseTitle = chapter.getDisplayTitle(useReplace = false) TocDomainItem( chapter = chapter, - displayTitle = cachedTitles[chapter.index] ?: baseTitle, + displayTitle = titleState.titles[chapter.index] ?: baseTitle, downloadState = DownloadState.LOCAL ) } @@ -296,7 +308,7 @@ class TocViewModel( val baseTitle = chapter.getDisplayTitle(useReplace = false) TocDomainItem( chapter, - cachedTitles[chapter.index] ?: baseTitle, + titleState.titles[chapter.index] ?: baseTitle, downloadState ) } @@ -352,6 +364,9 @@ class TocViewModel( downloadSummary = downloadSummary.value, useReplace = tocPreferences.value.useReplace, showWordCount = tocPreferences.value.showWordCount, + titleReplaceProgress = titleReplaceState.value + .takeIf { it.isRunning && it.total > 0 } + ?.let { it.completed.toFloat() / it.total }, ) } @@ -622,8 +637,8 @@ class TocViewModel( titleCacheJob?.cancel() titleCacheJob = null lastTitleCacheKey = null - if (titleReplaceCache.value.isNotEmpty()) { - titleReplaceCache.value = emptyMap() + if (titleReplaceState.value != TitleReplaceState()) { + titleReplaceState.value = TitleReplaceState() } return } @@ -646,20 +661,89 @@ class TocViewModel( chapterCount = chapters.size ) + val currentTitleState = titleReplaceState.value val isJobActive = titleCacheJob?.isActive == true - if (key == lastTitleCacheKey && (isJobActive || titleReplaceCache.value.isNotEmpty())) { + val isCurrentCacheReady = + currentTitleState.cacheKey == key && + currentTitleState.completed == chapters.size + if (key == lastTitleCacheKey && + (isJobActive || currentTitleState.isRunning || isCurrentCacheReady) + ) { return } lastTitleCacheKey = key titleCacheJob?.cancel() - titleReplaceCache.value = emptyMap() + titleReplaceState.value = TitleReplaceState( + cacheKey = key, + total = chapters.size, + isRunning = chapters.isNotEmpty() + ) + if (chapters.isEmpty()) { + titleCacheJob = null + return + } + titleCacheJob = viewModelScope.launch(Dispatchers.Default) { val newCache = HashMap(chapters.size) - chapters.forEach { chapter -> - newCache[chapter.index] = chapter.getDisplayTitle(replaceRules, true) + val workerCount = minOf(TITLE_REPLACE_WORKER_COUNT, chapters.size) + val publishBatchSize = + (chapters.size / MAX_TITLE_REPLACE_PROGRESS_UPDATES).coerceAtLeast(1) + var completed = 0 + var lastPublishedCompleted = 0 + var lastPublishedAt = System.nanoTime() + + chapters.asFlow() + .flatMapMerge(concurrency = workerCount) { chapter -> + flow { + emit(chapter.index to chapter.getDisplayTitle(replaceRules, true)) + } + } + .collect { (chapterIndex, displayTitle) -> + newCache[chapterIndex] = displayTitle + completed++ + val now = System.nanoTime() + val shouldPublish = completed < chapters.size && + (completed - lastPublishedCompleted >= publishBatchSize || + now - lastPublishedAt >= TITLE_REPLACE_UPDATE_INTERVAL_NANOS) + if (shouldPublish) { + titleReplaceState.update { current -> + if (current.cacheKey != key) { + current + } else { + TitleReplaceState( + cacheKey = key, + titles = HashMap(newCache), + completed = completed, + total = chapters.size, + isRunning = true + ) + } + } + lastPublishedCompleted = completed + lastPublishedAt = now + } + } + + titleReplaceState.update { current -> + if (current.cacheKey != key) { + current + } else { + TitleReplaceState( + cacheKey = key, + titles = newCache, + completed = chapters.size, + total = chapters.size, + isRunning = false + ) + } } - titleReplaceCache.value = newCache } } + + private companion object { + const val TITLE_REPLACE_WORKER_COUNT = 4 + const val MAX_TITLE_REPLACE_PROGRESS_UPDATES = 100 + const val TITLE_REPLACE_UPDATE_INTERVAL_NANOS = 100_000_000L + } }