fix: 优化目录替换处理体验
This commit is contained in:
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user