Show download progress and open the manga screen with less work

This commit is contained in:
AntsyLich
2026-10-11 13:43:35 +06:00
parent 8066b8dfc9
commit ba16158b13
5 changed files with 101 additions and 94 deletions
@@ -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<ChapterList.Item>, 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<ChapterList.Item>, 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<ChapterList.Item>, 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<ChapterList.Item>, 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 = {
@@ -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(),
@@ -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)
@@ -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<Long, DownloadProgress>())
private val downloadStates = MutableStateFlow(emptyMap<Long, Download.State>())
// Progress ticks often, so only the download indicators that read it recompose instead of every tick rebuilding
// the chapter list
private val downloadProgress = mutableStateMapOf<Long, Int>()
// 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<Chapter>.toChapterListItems(manga: Manga): List<ChapterList.Item> {
@@ -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<ChapterList.Item>)
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
@@ -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<Dialog?>(null)
private val downloadStates = MutableStateFlow(emptyMap<Long, DownloadProgress>())
private val downloadStates = MutableStateFlow(emptyMap<Long, Download.State>())
// Progress ticks often, so only the download indicators that read it recompose instead of every tick rebuilding
// the list
private val downloadProgress = mutableStateMapOf<Long, Int>()
// 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<Long>,
)
private data class DownloadProgress(val status: Download.State, val progress: Int)
@Immutable
data class State(
val isLoading: Boolean = true,