fix: 优化目录替换处理体验

This commit is contained in:
HapeLee committed 2026-07-01 01:02:56 +08:00
1 parent 13685b3364
commit 715e22681d
2 files changed
+111 -12

No files matched your search

@@ -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.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.progressIndicator.AppContainedLoadingIndicator 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.tabRow.AppTabRow
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.DynamicTopAppBar 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( TopFloatingStickyItem(
item = stickyVolume, item = stickyVolume,
modifier = Modifier modifier = Modifier
@@ -35,12 +35,15 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.scan
@@ -83,6 +86,7 @@ data class TocActionState(
val downloadSummary: String = "", val downloadSummary: String = "",
val useReplace: Boolean = false, val useReplace: Boolean = false,
val showWordCount: Boolean = true, val showWordCount: Boolean = true,
val titleReplaceProgress: Float? = null,
) : ListUiState<TocItemUi> ) : ListUiState<TocItemUi>
data class TocDomainItem( data class TocDomainItem(
@@ -116,6 +120,14 @@ private data class TitleCacheKey(
val chapterCount: Int val chapterCount: Int
) )
private data class TitleReplaceState(
val cacheKey: TitleCacheKey? = null,
val titles: Map<Int, String> = emptyMap(),
val completed: Int = 0,
val total: Int = 0,
val isRunning: Boolean = false
)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class TocViewModel( class TocViewModel(
application: Application, application: Application,
@@ -240,7 +252,7 @@ class TocViewModel(
TocUiConfig(collapsed, tocPreferences.useReplace, tocPreferences.showWordCount, isReverse) TocUiConfig(collapsed, tocPreferences.useReplace, tocPreferences.showWordCount, isReverse)
} }
private val titleReplaceCache = MutableStateFlow<Map<Int, String>>(emptyMap()) private val titleReplaceState = MutableStateFlow(TitleReplaceState())
private var titleCacheJob: Job? = null private var titleCacheJob: Job? = null
private var lastTitleCacheKey: TitleCacheKey? = null private var lastTitleCacheKey: TitleCacheKey? = null
@@ -249,8 +261,8 @@ class TocViewModel(
.flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) }, .flatMapLatest { appDb.bookChapterDao.getChapterListFlow(it) },
downloadContextFlow, downloadContextFlow,
uiConfigFlow, uiConfigFlow,
titleReplaceCache titleReplaceState
) { originalChapters, downloadCtx, config, cachedTitles -> ) { originalChapters, downloadCtx, config, titleState ->
val book = bookState.value ?: return@combine emptyList() val book = bookState.value ?: return@combine emptyList()
val processedChapters = if (config.isReverse) { val processedChapters = if (config.isReverse) {
@@ -275,7 +287,7 @@ class TocViewModel(
val baseTitle = chapter.getDisplayTitle(useReplace = false) val baseTitle = chapter.getDisplayTitle(useReplace = false)
TocDomainItem( TocDomainItem(
chapter = chapter, chapter = chapter,
displayTitle = cachedTitles[chapter.index] ?: baseTitle, displayTitle = titleState.titles[chapter.index] ?: baseTitle,
downloadState = DownloadState.LOCAL downloadState = DownloadState.LOCAL
) )
} }
@@ -296,7 +308,7 @@ class TocViewModel(
val baseTitle = chapter.getDisplayTitle(useReplace = false) val baseTitle = chapter.getDisplayTitle(useReplace = false)
TocDomainItem( TocDomainItem(
chapter, chapter,
cachedTitles[chapter.index] ?: baseTitle, titleState.titles[chapter.index] ?: baseTitle,
downloadState downloadState
) )
} }
@@ -352,6 +364,9 @@ class TocViewModel(
downloadSummary = downloadSummary.value, downloadSummary = downloadSummary.value,
useReplace = tocPreferences.value.useReplace, useReplace = tocPreferences.value.useReplace,
showWordCount = tocPreferences.value.showWordCount, 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?.cancel()
titleCacheJob = null titleCacheJob = null
lastTitleCacheKey = null lastTitleCacheKey = null
if (titleReplaceCache.value.isNotEmpty()) { if (titleReplaceState.value != TitleReplaceState()) {
titleReplaceCache.value = emptyMap() titleReplaceState.value = TitleReplaceState()
} }
return return
} }
@@ -646,20 +661,89 @@ class TocViewModel(
chapterCount = chapters.size chapterCount = chapters.size
) )
val currentTitleState = titleReplaceState.value
val isJobActive = titleCacheJob?.isActive == true 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 return
} }
lastTitleCacheKey = key lastTitleCacheKey = key
titleCacheJob?.cancel() 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) { titleCacheJob = viewModelScope.launch(Dispatchers.Default) {
val newCache = HashMap<Int, String>(chapters.size) val newCache = HashMap<Int, String>(chapters.size)
chapters.forEach { chapter -> val workerCount = minOf(TITLE_REPLACE_WORKER_COUNT, chapters.size)
newCache[chapter.index] = chapter.getDisplayTitle(replaceRules, true) 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
}
} }