From ba16158b1399a80f521618a789c68b609546316d Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:15:37 +0600 Subject: [PATCH] Show download progress and open the manga screen with less work --- .../kanade/presentation/manga/MangaScreen.kt | 73 ++++++------------- .../manga/components/MarkdownRender.kt | 10 +-- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 1 + .../tachiyomi/ui/manga/MangaViewModel.kt | 71 +++++++++++++----- .../tachiyomi/ui/updates/UpdatesViewModel.kt | 40 +++++----- 5 files changed, 101 insertions(+), 94 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 69a94c0df..7e7d8175d 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -63,7 +63,6 @@ import eu.kanade.tachiyomi.util.system.copyToClipboard import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.roundedfilled.PlayArrow import tachiyomi.domain.chapter.model.Chapter -import tachiyomi.domain.chapter.service.missingChaptersCount import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.model.StubSource @@ -88,6 +87,7 @@ fun MangaScreen( navigateUp: () -> Unit, onChapterClicked: (Chapter) -> Unit, onDownloadChapter: ((List, ChapterDownloadAction) -> Unit)?, + downloadProgressProvider: (chapterId: Long) -> Int, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, @@ -143,6 +143,7 @@ fun MangaScreen( navigateUp = navigateUp, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, + downloadProgressProvider = downloadProgressProvider, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, @@ -179,6 +180,7 @@ fun MangaScreen( navigateUp = navigateUp, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, + downloadProgressProvider = downloadProgressProvider, onAddToLibraryClicked = onAddToLibraryClicked, onWebViewClicked = onWebViewClicked, onWebViewLongClicked = onWebViewLongClicked, @@ -218,6 +220,7 @@ private fun MangaScreenSmallImpl( navigateUp: () -> Unit, onChapterClicked: (Chapter) -> Unit, onDownloadChapter: ((List, ChapterDownloadAction) -> Unit)?, + downloadProgressProvider: (chapterId: Long) -> Int, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, @@ -273,9 +276,6 @@ private fun MangaScreenSmallImpl( Scaffold( topBar = { - val selectedChapterCount: Int = remember(chapters) { - chapters.count { it.selected } - } val isFirstItemVisible by remember { derivedStateOf { chapterListState.firstVisibleItemIndex == 0 } } @@ -301,7 +301,7 @@ private fun MangaScreenSmallImpl( onClickRefresh = onRefresh, onClickMigrate = onMigrateClicked, onClickEditNotes = onEditNotesClicked, - actionModeCounter = selectedChapterCount, + actionModeCounter = state.selectedChapters.size, onCancelActionMode = { onAllChapterSelected(false) }, onSelectAll = { onAllChapterSelected(true) }, onInvertSelection = { onInvertSelection() }, @@ -310,11 +310,8 @@ private fun MangaScreenSmallImpl( ) }, bottomBar = { - val selectedChapters = remember(chapters) { - chapters.filter { it.selected } - } SharedMangaBottomActionMenu( - selected = selectedChapters, + selected = state.selectedChapters, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, @@ -325,16 +322,13 @@ private fun MangaScreenSmallImpl( }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { - val isFABVisible = remember(chapters) { - chapters.fastAny { !it.chapter.read } && !isAnySelected - } + val isFABVisible = state.hasUnreadChapters && !isAnySelected SmallExtendedFloatingActionButton( text = { - val isReading = remember(state.chapters) { - state.chapters.fastAny { it.chapter.read } - } Text( - text = stringResource(if (isReading) MR.strings.action_resume else MR.strings.action_start), + text = stringResource( + if (state.hasReadChapters) MR.strings.action_resume else MR.strings.action_start, + ), ) }, icon = { Icon(imageVector = MaterialSymbols.RoundedFilled.PlayArrow, contentDescription = null) }, @@ -422,17 +416,10 @@ private fun MangaScreenSmallImpl( key = MangaScreenItem.CHAPTER_HEADER, contentType = MangaScreenItem.CHAPTER_HEADER, ) { - val missingChapterCount = remember(chapters, state.hideMissingChapters) { - if (state.hideMissingChapters) { - return@remember 0 - } - - chapters.map { it.chapter.chapterNumber }.missingChaptersCount() - } ChapterHeader( enabled = !isAnySelected, chapterCount = chapters.size, - missingChapterCount = missingChapterCount, + missingChapterCount = state.missingChapterCount, onClick = onFilterClicked, ) } @@ -440,11 +427,12 @@ private fun MangaScreenSmallImpl( sharedChapterItems( manga = state.manga, chapters = listItem, - isAnyChapterSelected = chapters.fastAny { it.selected }, + isAnyChapterSelected = state.selectedChapters.isNotEmpty(), chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, + downloadProgressProvider = downloadProgressProvider, onChapterSelected = onChapterSelected, onChapterSwipe = onChapterSwipe, ) @@ -464,6 +452,7 @@ fun MangaScreenLargeImpl( navigateUp: () -> Unit, onChapterClicked: (Chapter) -> Unit, onDownloadChapter: ((List, ChapterDownloadAction) -> Unit)?, + downloadProgressProvider: (chapterId: Long) -> Int, onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, @@ -525,9 +514,6 @@ fun MangaScreenLargeImpl( Scaffold( topBar = { - val selectedChapterCount = remember(chapters) { - chapters.count { it.selected } - } MangaToolbar( modifier = Modifier.onSizeChanged { topBarHeight = it.height }, title = state.manga.title, @@ -541,7 +527,7 @@ fun MangaScreenLargeImpl( onClickMigrate = onMigrateClicked, onClickEditNotes = onEditNotesClicked, onCancelActionMode = { onAllChapterSelected(false) }, - actionModeCounter = selectedChapterCount, + actionModeCounter = state.selectedChapters.size, onSelectAll = { onAllChapterSelected(true) }, onInvertSelection = { onInvertSelection() }, titleAlphaProvider = { 1f }, @@ -553,11 +539,8 @@ fun MangaScreenLargeImpl( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.BottomEnd, ) { - val selectedChapters = remember(chapters) { - chapters.filter { it.selected } - } SharedMangaBottomActionMenu( - selected = selectedChapters, + selected = state.selectedChapters, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, @@ -569,17 +552,12 @@ fun MangaScreenLargeImpl( }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, floatingActionButton = { - val isFABVisible = remember(chapters) { - chapters.fastAny { !it.chapter.read } && !isAnySelected - } + val isFABVisible = state.hasUnreadChapters && !isAnySelected SmallExtendedFloatingActionButton( text = { - val isReading = remember(state.chapters) { - state.chapters.fastAny { it.chapter.read } - } Text( text = stringResource( - if (isReading) MR.strings.action_resume else MR.strings.action_start, + if (state.hasReadChapters) MR.strings.action_resume else MR.strings.action_start, ), ) }, @@ -663,17 +641,10 @@ fun MangaScreenLargeImpl( key = MangaScreenItem.CHAPTER_HEADER, contentType = MangaScreenItem.CHAPTER_HEADER, ) { - val missingChapterCount = remember(chapters, state.hideMissingChapters) { - if (state.hideMissingChapters) { - return@remember 0 - } - - chapters.map { it.chapter.chapterNumber }.missingChaptersCount() - } ChapterHeader( enabled = !isAnySelected, chapterCount = chapters.size, - missingChapterCount = missingChapterCount, + missingChapterCount = state.missingChapterCount, onClick = onFilterButtonClicked, ) } @@ -681,11 +652,12 @@ fun MangaScreenLargeImpl( sharedChapterItems( manga = state.manga, chapters = listItem, - isAnyChapterSelected = chapters.fastAny { it.selected }, + isAnyChapterSelected = state.selectedChapters.isNotEmpty(), chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onChapterClicked = onChapterClicked, onDownloadChapter = onDownloadChapter, + downloadProgressProvider = downloadProgressProvider, onChapterSelected = onChapterSelected, onChapterSwipe = onChapterSwipe, ) @@ -747,6 +719,7 @@ private fun LazyListScope.sharedChapterItems( chapterSwipeEndAction: LibraryPreferences.ChapterSwipeAction, onChapterClicked: (Chapter) -> Unit, onDownloadChapter: ((List, ChapterDownloadAction) -> Unit)?, + downloadProgressProvider: (chapterId: Long) -> Int, onChapterSelected: (ChapterList.Item, Boolean, Boolean) -> Unit, onChapterSwipe: (ChapterList.Item, LibraryPreferences.ChapterSwipeAction) -> Unit, ) { @@ -791,7 +764,7 @@ private fun LazyListScope.sharedChapterItems( selected = item.selected, downloadIndicatorEnabled = !isAnyChapterSelected && !manga.isLocal(), downloadStateProvider = { item.downloadState }, - downloadProgressProvider = { item.downloadProgress }, + downloadProgressProvider = { downloadProgressProvider(item.id) }, chapterSwipeStartAction = chapterSwipeStartAction, chapterSwipeEndAction = chapterSwipeEndAction, onLongClick = { diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MarkdownRender.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MarkdownRender.kt index 72c77d679..929bafa96 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MarkdownRender.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MarkdownRender.kt @@ -45,7 +45,7 @@ import com.mikepenz.markdown.model.MarkdownTypography import com.mikepenz.markdown.model.NoOpImageTransformerImpl import com.mikepenz.markdown.model.markdownAlertPadding import com.mikepenz.markdown.model.markdownAnnotator -import com.mikepenz.markdown.model.rememberMarkdownState +import com.mikepenz.markdown.model.parseMarkdown import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Image import org.intellij.markdown.MarkdownTokenTypes.Companion.HTML_TAG @@ -79,11 +79,9 @@ fun MarkdownRender( loadImages: Boolean = true, ) { Markdown( - markdownState = rememberMarkdownState( - content = content, - flavour = flavour, - immediate = true, - ), + // rememberMarkdownState parses again in the background even when it already parsed immediately, and its + // new result recomposes the whole content a second time + state = remember(content, flavour) { parseMarkdown(content = content, flavour = flavour) }, annotator = annotator, colors = getMarkdownColors(), typography = getMarkdownTypography(), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 15ffab2c7..8b0f2fb19 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -114,6 +114,7 @@ class MangaScreen( navigateUp = navigator::pop, onChapterClicked = { openChapter(context, it) }, onDownloadChapter = viewModel::runChapterDownloadActions.takeIf { !successState.source.isLocalOrStub() }, + downloadProgressProvider = viewModel::getDownloadProgress, onAddToLibraryClicked = { viewModel.toggleFavorite() haptic.performHapticFeedback(HapticFeedbackType.LongPress) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt index f54cecb5c..d3a241731 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaViewModel.kt @@ -6,7 +6,10 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastFilter +import androidx.compose.ui.util.fastMap import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dev.zacsweers.metro.AppScope @@ -61,10 +64,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -91,6 +92,7 @@ import tachiyomi.domain.chapter.model.ChapterUpdate import tachiyomi.domain.chapter.model.NoChaptersException import tachiyomi.domain.chapter.service.calculateChapterGap import tachiyomi.domain.chapter.service.getChapterSort +import tachiyomi.domain.chapter.service.missingChaptersCount import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.interactor.GetDuplicateLibraryManga import tachiyomi.domain.manga.interactor.GetMangaWithChapters @@ -103,7 +105,6 @@ import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.i18n.MR import tachiyomi.source.local.isLocal import kotlin.math.floor -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @AssistedInject @@ -185,7 +186,11 @@ class MangaViewModel( private val isRefreshingData = MutableStateFlow(false) - private val downloadStates = MutableStateFlow(emptyMap()) + private val downloadStates = MutableStateFlow(emptyMap()) + + // Progress ticks often, so only the download indicators that read it recompose instead of every tick rebuilding + // the chapter list + private val downloadProgress = mutableStateMapOf() // A finished chapter leaves the queue, and its last status change can be lost with it, so its state is left to // the queried item once it's no longer queued @@ -262,12 +267,11 @@ class MangaViewModel( source = mangaAndChapters.source, isFromSource = isFromSource, chapters = mangaAndChapters.chapters.map { item -> - val download = downloads[item.id] + val downloadState = downloads[item.id] val selected = item.id in selectedIds - if (download == null && !selected) return@map item + if (downloadState == null && !selected) return@map item item.copy( - downloadState = download?.status ?: item.downloadState, - downloadProgress = download?.progress ?: item.downloadProgress, + downloadState = downloadState ?: item.downloadState, selected = selected, ) }, @@ -280,27 +284,32 @@ class MangaViewModel( hideMissingChapters = hideMissingChapters, ) } - // Composition would otherwise build these on first read, on the main thread, for every download progress tick + // Composition would otherwise build these on first read, on the main thread, for every new state .onEach { it.processedChapters it.chapterListItems it.isAnySelected + it.selectedChapters + it.hasUnreadChapters + it.hasReadChapters + it.missingChapterCount } .flowOn(Dispatchers.IO) .stateIn(viewModelScope + subscriberGate, subscriberGate.whileSubscribed(5.seconds), State.Loading) init { - // Progress ticks often, and each one copies the map of download states viewModelScope.launch(Dispatchers.Default) { - merge( - downloadManager.statusFlow(), - // Each tick rebuilds the shown chapter items, so cap how often that happens - downloadManager.progressFlow().sample(200.milliseconds), - ) + downloadManager.statusFlow() .filter { it.manga.id == mangaId } .catch { logcat(LogPriority.ERROR, it) } .collect(::updateDownloadState) } + viewModelScope.launch(Dispatchers.Default) { + downloadManager.progressFlow() + .filter { it.manga.id == mangaId } + .catch { logcat(LogPriority.ERROR, it) } + .collect { downloadProgress[it.chapter.id] = it.progress } + } viewModelScope.launch { // The state loads the entry and its chapters anyway, so they aren't queried separately @@ -560,9 +569,18 @@ class MangaViewModel( if (download.status == Download.State.NOT_DOWNLOADED || download.status == Download.State.DOWNLOADED) { it - chapterId } else { - it + (chapterId to DownloadProgress(download.status, download.progress)) + it + (chapterId to download.status) } } + if (download.status == Download.State.DOWNLOADING) { + downloadProgress[chapterId] = download.progress + } else { + downloadProgress.remove(chapterId) + } + } + + fun getDownloadProgress(chapterId: Long): Int { + return downloadProgress[chapterId] ?: 0 } private fun List.toChapterListItems(manga: Manga): List { @@ -581,7 +599,6 @@ class MangaViewModel( ChapterList.Item( chapter = chapter, downloadState = downloadState, - downloadProgress = activeDownload?.progress ?: 0, ) } } @@ -1074,8 +1091,6 @@ class MangaViewModel( private data class MangaAndChapters(val manga: Manga, val source: Source, val chapters: List) - private data class DownloadProgress(val status: Download.State, val progress: Int) - sealed interface State { @Immutable data object Loading : State @@ -1102,6 +1117,23 @@ class MangaViewModel( chapters.fastAny { it.selected } } + val selectedChapters by lazy { + processedChapters.fastFilter { it.selected } + } + + val hasUnreadChapters by lazy { + processedChapters.fastAny { !it.chapter.read } + } + + val hasReadChapters by lazy { + chapters.fastAny { it.chapter.read } + } + + val missingChapterCount by lazy { + if (hideMissingChapters) return@lazy 0 + processedChapters.fastMap { it.chapter.chapterNumber }.missingChaptersCount() + } + val chapterListItems by lazy { if (hideMissingChapters) { return@lazy processedChapters @@ -1171,7 +1203,6 @@ sealed class ChapterList { data class Item( val chapter: Chapter, val downloadState: Download.State, - val downloadProgress: Int, val selected: Boolean = false, ) : ChapterList() { val id = chapter.id diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt index 6b3a76485..0b6bd6947 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/updates/UpdatesViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.ui.util.fastFilter import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -37,9 +38,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -64,7 +63,6 @@ import tachiyomi.domain.updates.interactor.GetUpdates import tachiyomi.domain.updates.model.UpdatesWithRelations import tachiyomi.domain.updates.service.UpdatesPreferences import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @Inject @@ -100,7 +98,11 @@ class UpdatesViewModel( private val dialog = MutableStateFlow(null) - private val downloadStates = MutableStateFlow(emptyMap()) + private val downloadStates = MutableStateFlow(emptyMap()) + + // Progress ticks often, so only the download indicators that read it recompose instead of every tick rebuilding + // the list + private val downloadProgress = mutableStateMapOf() // A finished chapter leaves the queue, and its last status change can be lost with it, so its state is left to // the queried item once it's no longer queued @@ -111,11 +113,15 @@ class UpdatesViewModel( init { viewModelScope.launch { - // Each progress tick rebuilds every row of the list, so cap how often that happens - merge(downloadManager.statusFlow(), downloadManager.progressFlow().sample(200.milliseconds)) + downloadManager.statusFlow() .catch { logcat(LogPriority.ERROR, it) } .collect(this@UpdatesViewModel::updateDownloadState) } + viewModelScope.launch { + downloadManager.progressFlow() + .catch { logcat(LogPriority.ERROR, it) } + .collect { downloadProgress[it.chapter.id] = it.progress } + } } private fun updateDownloadState(download: Download) { @@ -126,9 +132,14 @@ class UpdatesViewModel( if (download.status == Download.State.NOT_DOWNLOADED || download.status == Download.State.DOWNLOADED) { it - chapterId } else { - it + (chapterId to DownloadProgress(download.status, download.progress)) + it + (chapterId to download.status) } } + if (download.status == Download.State.DOWNLOADING) { + downloadProgress[chapterId] = download.progress + } else { + downloadProgress.remove(chapterId) + } } private val hasActiveFilters = getUpdatesItemPreferenceFlow() @@ -189,19 +200,14 @@ class UpdatesViewModel( isLoading = items == null, hasActiveFilters = hasActiveFilters, items = items.orEmpty().map { item -> - val download = downloads[item.update.chapterId] + val downloadState = downloads[item.update.chapterId] item.copy( selected = item.update.chapterId in selectedIds, - downloadStateProvider = if (download != null) { - { download.status } + downloadStateProvider = if (downloadState != null) { + { downloadState } } else { item.downloadStateProvider }, - downloadProgressProvider = if (download != null) { - { download.progress } - } else { - item.downloadProgressProvider - }, ) }, dialog = dialog, @@ -246,7 +252,7 @@ class UpdatesViewModel( UpdatesItem( update = update, downloadStateProvider = { downloadState }, - downloadProgressProvider = { activeDownload?.progress ?: 0 }, + downloadProgressProvider = { downloadProgress[update.chapterId] ?: 0 }, ) } } @@ -489,8 +495,6 @@ class UpdatesViewModel( val filterExcludedCategories: List, ) - private data class DownloadProgress(val status: Download.State, val progress: Int) - @Immutable data class State( val isLoading: Boolean = true,