From 7ff97761b9e6bb6ad7ef22ff53a09faa280b9b76 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sun, 4 Oct 2026 22:25:06 +0600 Subject: [PATCH] Make MangaViewModel collect the entry only while subscribed --- .../tachiyomi/ui/manga/MangaViewModel.kt | 461 ++++++++---------- 1 file changed, 209 insertions(+), 252 deletions(-) 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 b0aab2e04..f21c793e6 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 @@ -46,15 +46,25 @@ import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +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.onStart +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import logcat.LogPriority import mihon.domain.chapter.interactor.FilterChaptersForDownload @@ -84,12 +94,12 @@ import tachiyomi.domain.manga.interactor.SetMangaChapterFlags import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaWithChapterCount import tachiyomi.domain.manga.model.applyFilter -import tachiyomi.domain.manga.repository.MangaRepository import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.i18n.MR import tachiyomi.source.local.isLocal import kotlin.math.floor +import kotlin.time.Duration.Companion.seconds @AssistedInject class MangaViewModel( @@ -117,7 +127,6 @@ class MangaViewModel( private val getTracks: GetTracks, private val addTracks: AddTracks, private val setMangaCategories: SetMangaCategories, - private val mangaRepository: MangaRepository, private val filterChaptersForDownload: FilterChaptersForDownload, private val updateMangaFromRemote: UpdateMangaFromRemote, private val sourceManager: SourceManager, @@ -125,9 +134,6 @@ class MangaViewModel( private val coverCache: CoverCache, ) : ViewModel() { - val state: StateFlow - field = MutableStateFlow(State.Loading) - @AssistedFactory @ManualViewModelAssistedFactoryKey @ContributesIntoMap(AppScope::class) @@ -165,126 +171,163 @@ class MangaViewModel( LibraryPreferences.MANGA_OUTSIDE_RELEASE_PERIOD in libraryPreferences.autoUpdateMangaRestrictions.get() private val selectedPositions: Array = arrayOf(-1, -1) // first and last selected index in list - private val selectedChapterIds: HashSet = HashSet() + private val selectedChapterIds = MutableStateFlow(emptySet()) - /** - * Helper function to update the UI state only if it's currently in success state - */ - private inline fun updateSuccessState(func: (State.Success) -> State.Success) { - state.update { - when (it) { - State.Loading -> it - is State.Success -> func(it) - } + private val dialog = MutableStateFlow(null) + + private val isRefreshingData = MutableStateFlow(false) + + private val downloadStates = MutableStateFlow(emptyMap()) + + // 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 + private val queuedDownloadStates = combine(downloadStates, downloadManager.queueState) { states, queue -> + val queuedChapterIds = queue.mapTo(HashSet()) { it.chapter.id } + states.filterKeys { it in queuedChapterIds } + } + + private val hideMissingChapters = libraryPreferences.hideMissingChapters.get() + + private var hasPromptedToAddBefore = false + + private val defaultChapterFlagsJob = viewModelScope.launchIO { + val manga = getMangaAndChapters.awaitManga(mangaId) + if (!manga.favorite) { + setMangaDefaultChapterFlags.await(manga) } } + private val mangaAndChapters = combine( + flow { + // So an entry outside the library isn't shown with its old chapter settings first + defaultChapterFlagsJob.join() + emitAll(getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true)) + } + .distinctUntilChanged(), + downloadCache.changes, + downloadManager.queueState, + ) { (manga, chapters), _, _ -> + MangaAndChapters( + manga = manga, + source = sourceManager.getOrStub(manga.source), + chapters = chapters.toChapterListItems(manga), + ) + } + + private val scanlators = combine( + getAvailableScanlators.subscribe(mangaId).distinctUntilChanged(), + getExcludedScanlators.subscribe(mangaId).distinctUntilChanged(), + ::Pair, + ) + + private val trackers = flow { emit(sourceManager.getOrStub(getMangaAndChapters.awaitManga(mangaId).source)) } + .flatMapLatest { source -> + combine( + getTracks.subscribe(mangaId).catch { logcat(LogPriority.ERROR, it) }, + trackerManager.loggedInTrackersFlow(), + ) { mangaTracks, loggedInTrackers -> + // Show only if the service supports this manga's source + val supportedTrackers = loggedInTrackers.filter { (it as? EnhancedTracker)?.accept(source) ?: true } + val supportedTrackerIds = supportedTrackers.map { it.id }.toHashSet() + val supportedTrackerTracks = mangaTracks.filter { it.trackerId in supportedTrackerIds } + supportedTrackerTracks.size to supportedTrackers.isNotEmpty() + } + } + .distinctUntilChanged() + .onStart { emit(0 to false) } + + val state: StateFlow = combine( + mangaAndChapters, + scanlators, + trackers, + combine(selectedChapterIds, queuedDownloadStates, ::Pair), + combine(dialog, isRefreshingData, ::Pair), + ) { + mangaAndChapters, + (availableScanlators, excludedScanlators), + (trackingCount, hasLoggedInTrackers), + (selectedIds, downloads), + (dialog, isRefreshingData), + -> + State.Success( + manga = mangaAndChapters.manga, + source = mangaAndChapters.source, + isFromSource = isFromSource, + chapters = mangaAndChapters.chapters.map { item -> + val download = downloads[item.id] + val selected = item.id in selectedIds + if (download == null && !selected) return@map item + item.copy( + downloadState = download?.status ?: item.downloadState, + downloadProgress = download?.progress ?: item.downloadProgress, + selected = selected, + ) + }, + availableScanlators = availableScanlators, + excludedScanlators = excludedScanlators, + trackingCount = trackingCount, + hasLoggedInTrackers = hasLoggedInTrackers, + isRefreshingData = isRefreshingData, + dialog = dialog, + hideMissingChapters = hideMissingChapters, + ) + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State.Loading) + init { viewModelScope.launchIO { - combine( - getMangaAndChapters.subscribe(mangaId, applyScanlatorFilter = true).distinctUntilChanged(), - downloadCache.changes, - downloadManager.queueState, - ) { mangaAndChapters, _, _ -> mangaAndChapters } - .collectLatest { (manga, chapters) -> - updateSuccessState { - it.copy( - manga = manga, - chapters = chapters.toChapterListItems(manga), - ) - } - } + merge(downloadManager.statusFlow(), downloadManager.progressFlow()) + .filter { it.manga.id == mangaId } + .catch { logcat(LogPriority.ERROR, it) } + .collect(::updateDownloadState) } viewModelScope.launchIO { - getExcludedScanlators.subscribe(mangaId) - .distinctUntilChanged() - .collectLatest { excludedScanlators -> - updateSuccessState { - it.copy(excludedScanlators = excludedScanlators) - } - } - } - - viewModelScope.launchIO { - getAvailableScanlators.subscribe(mangaId) - .distinctUntilChanged() - .collectLatest { availableScanlators -> - updateSuccessState { - it.copy(availableScanlators = availableScanlators) - } - } - } - - observeDownloads() - - viewModelScope.launchIO { - val manga = getMangaAndChapters.awaitManga(mangaId) - val chapters = getMangaAndChapters.awaitChapters(mangaId, applyScanlatorFilter = true) - .toChapterListItems(manga) - - if (!manga.favorite) { - setMangaDefaultChapterFlags.await(manga) - } - + // The state loads the entry and its chapters anyway, so they aren't queried separately + val loadedState = state.filterIsInstance().first() + val manga = loadedState.manga val needRefreshInfo = !manga.initialized - val needRefreshChapter = chapters.isEmpty() - - // Show what we have earlier - state.update { - State.Success( - manga = manga, - source = sourceManager.getOrStub(manga.source), - isFromSource = isFromSource, - chapters = chapters, - availableScanlators = getAvailableScanlators.await(mangaId), - excludedScanlators = getExcludedScanlators.await(mangaId), - isRefreshingData = needRefreshInfo || needRefreshChapter, - dialog = null, - hideMissingChapters = libraryPreferences.hideMissingChapters.get(), - ) - } - - // Start observe tracking since it only needs mangaId - observeTrackers() + val needRefreshChapter = loadedState.chapters.isEmpty() // Fetch info-chapters when needed - if ((needRefreshInfo || needRefreshChapter) && viewModelScope.isActive) { + if (needRefreshInfo || needRefreshChapter) { + isRefreshingData.value = true fetchAllFromSource( + manga = manga, manualFetch = false, fetchDetails = needRefreshInfo, fetchChapters = needRefreshChapter, ) + isRefreshingData.value = false } - - // Initial loading finished - updateSuccessState { it.copy(isRefreshingData = false) } } } fun fetchAllFromSource(manualFetch: Boolean = true) { viewModelScope.launch { - updateSuccessState { it.copy(isRefreshingData = true) } + isRefreshingData.value = true fetchAllFromSource( + manga = getMangaAndChapters.awaitManga(mangaId), manualFetch = manualFetch, fetchDetails = true, fetchChapters = true, ) - updateSuccessState { it.copy(isRefreshingData = false) } + isRefreshingData.value = false } } private suspend fun fetchAllFromSource( + manga: Manga, manualFetch: Boolean, fetchDetails: Boolean, fetchChapters: Boolean, ) { - val state = successState ?: return try { withUIContext { val update = updateMangaFromRemote( - source = state.source, - manga = state.manga, + source = sourceManager.getOrStub(manga.source), + manga = manga, fetchDetails = fetchDetails, fetchChapters = fetchChapters, manualFetch = manualFetch, @@ -358,7 +401,7 @@ class MangaViewModel( val duplicates = getDuplicateLibraryManga(manga) if (duplicates.isNotEmpty()) { - updateSuccessState { it.copy(dialog = Dialog.DuplicateManga(manga, duplicates)) } + dialog.value = Dialog.DuplicateManga(manga, duplicates) return@launchIO } } @@ -397,35 +440,24 @@ class MangaViewModel( viewModelScope.launch { val categories = getCategories() val selection = getMangaCategoryIds(manga) - updateSuccessState { successState -> - successState.copy( - dialog = Dialog.ChangeCategory( - manga = manga, - initialSelection = categories.mapAsCheckboxState { it.id in selection }, - ), - ) - } + dialog.value = Dialog.ChangeCategory( + manga = manga, + initialSelection = categories.mapAsCheckboxState { it.id in selection }, + ) } } fun showSetFetchIntervalDialog() { val manga = successState?.manga ?: return - updateSuccessState { - it.copy(dialog = Dialog.SetFetchInterval(manga)) - } + dialog.value = Dialog.SetFetchInterval(manga) } fun setFetchInterval(manga: Manga, interval: Int) { viewModelScope.launchIO { - if ( - updateManga.awaitUpdateFetchInterval( - // Custom intervals are negative - manga.copy(fetchInterval = -interval), - ) - ) { - val updatedManga = mangaRepository.getMangaById(manga.id) - updateSuccessState { it.copy(manga = updatedManga) } - } + updateManga.awaitUpdateFetchInterval( + // Custom intervals are negative + manga.copy(fetchInterval = -interval), + ) } } @@ -503,41 +535,16 @@ class MangaViewModel( // Chapters list - start - private fun observeDownloads() { - viewModelScope.launchIO { - downloadManager.statusFlow() - .filter { it.manga.id == successState?.manga?.id } - .catch { error -> logcat(LogPriority.ERROR, error) } - .collect { - withUIContext { - updateDownloadState(it) - } - } - } - - viewModelScope.launchIO { - downloadManager.progressFlow() - .filter { it.manga.id == successState?.manga?.id } - .catch { error -> logcat(LogPriority.ERROR, error) } - .collect { - withUIContext { - updateDownloadState(it) - } - } - } - } - private fun updateDownloadState(download: Download) { - updateSuccessState { successState -> - val modifiedIndex = successState.chapters.indexOfFirst { it.id == download.chapter.id } - if (modifiedIndex < 0) return@updateSuccessState successState - - val newChapters = successState.chapters.toMutableList().apply { - val item = removeAt(modifiedIndex) - .copy(downloadState = download.status, downloadProgress = download.progress) - add(modifiedIndex, item) + val chapterId = download.chapter.id + downloadStates.update { + // Terminal states are derived by the queried item itself, so drop the override instead + // of letting it outlive reality, e.g. showing a since deleted chapter as downloaded. + if (download.status == Download.State.NOT_DOWNLOADED || download.status == Download.State.DOWNLOADED) { + it - chapterId + } else { + it + (chapterId to DownloadProgress(download.status, download.progress)) } - successState.copy(chapters = newChapters) } } @@ -558,7 +565,6 @@ class MangaViewModel( chapter = chapter, downloadState = downloadState, downloadProgress = activeDownload?.progress ?: 0, - selected = chapter.id in selectedChapterIds, ) } } @@ -638,7 +644,7 @@ class MangaViewModel( chapters: List, startNow: Boolean, ) { - val successState = successState ?: return + if (successState == null) return viewModelScope.launchNonCancellable { if (startNow) { @@ -648,10 +654,8 @@ class MangaViewModel( downloadChapters(chapters) } - if (!isFavorited && !successState.hasPromptedToAddBefore) { - updateSuccessState { state -> - state.copy(hasPromptedToAddBefore = true) - } + if (!isFavorited && !hasPromptedToAddBefore) { + hasPromptedToAddBefore = true val result = snackbarHostState.showSnackbar( message = context.stringResource(MR.strings.snack_add_to_library), actionLabel = context.stringResource(MR.strings.action_add), @@ -938,120 +942,70 @@ class MangaViewModel( selected: Boolean, fromLongPress: Boolean = false, ) { - updateSuccessState { successState -> - val newChapters = successState.processedChapters.toMutableList().apply { - val selectedIndex = successState.processedChapters.indexOfFirst { it.id == item.chapter.id } - if (selectedIndex < 0) return@apply + val chapters = successState?.processedChapters ?: return + val selectedIndex = chapters.indexOfFirst { it.id == item.chapter.id } + if (selectedIndex < 0) return - val selectedItem = get(selectedIndex) - if ((selectedItem.selected && selected) || (!selectedItem.selected && !selected)) return@apply + // Read from the ids, as the state catches up with a selection only after it's been made + val selectedIds = HashSet(selectedChapterIds.value) + if ((item.id in selectedIds) == selected) return - val firstSelection = none { it.selected } - set(selectedIndex, selectedItem.copy(selected = selected)) - selectedChapterIds.addOrRemove(item.id, selected) + val firstSelection = chapters.none { it.id in selectedIds } + selectedIds.addOrRemove(item.id, selected) - if (selected && fromLongPress) { - if (firstSelection) { - selectedPositions[0] = selectedIndex - selectedPositions[1] = selectedIndex - } else { - // Try to select the items in-between when possible - val range: IntRange - if (selectedIndex < selectedPositions[0]) { - range = selectedIndex + 1.. selectedPositions[1]) { - range = (selectedPositions[1] + 1).. selectedPositions[1]) { + range = (selectedPositions[1] + 1).. selectedPositions[1]) { - selectedPositions[1] = selectedIndex - } - } + range.forEach { selectedIds.add(chapters[it].id) } + } + } else if (!fromLongPress) { + if (!selected) { + if (selectedIndex == selectedPositions[0]) { + selectedPositions[0] = chapters.indexOfFirst { it.id in selectedIds } + } else if (selectedIndex == selectedPositions[1]) { + selectedPositions[1] = chapters.indexOfLast { it.id in selectedIds } + } + } else { + if (selectedIndex < selectedPositions[0]) { + selectedPositions[0] = selectedIndex + } else if (selectedIndex > selectedPositions[1]) { + selectedPositions[1] = selectedIndex } } - successState.copy(chapters = newChapters) } + selectedChapterIds.value = selectedIds } fun toggleAllSelection(selected: Boolean) { - updateSuccessState { successState -> - val newChapters = successState.chapters.map { - selectedChapterIds.addOrRemove(it.id, selected) - it.copy(selected = selected) - } - selectedPositions[0] = -1 - selectedPositions[1] = -1 - successState.copy(chapters = newChapters) - } + selectedChapterIds.value = if (selected) allChapters.orEmpty().mapTo(HashSet()) { it.id } else emptySet() + selectedPositions[0] = -1 + selectedPositions[1] = -1 } fun invertSelection() { - updateSuccessState { successState -> - val newChapters = successState.chapters.map { - selectedChapterIds.addOrRemove(it.id, !it.selected) - it.copy(selected = !it.selected) - } - selectedPositions[0] = -1 - selectedPositions[1] = -1 - successState.copy(chapters = newChapters) - } + val selectedIds = selectedChapterIds.value + selectedChapterIds.value = allChapters.orEmpty().filterNot { it.id in selectedIds }.mapTo(HashSet()) { it.id } + selectedPositions[0] = -1 + selectedPositions[1] = -1 } // Chapters list - end - // Track sheet - start - - private fun observeTrackers() { - val manga = successState?.manga ?: return - - viewModelScope.launchIO { - combine( - getTracks.subscribe(manga.id).catch { logcat(LogPriority.ERROR, it) }, - trackerManager.loggedInTrackersFlow(), - ) { mangaTracks, loggedInTrackers -> - // Show only if the service supports this manga's source - val supportedTrackers = loggedInTrackers.filter { (it as? EnhancedTracker)?.accept(source!!) ?: true } - val supportedTrackerIds = supportedTrackers.map { it.id }.toHashSet() - val supportedTrackerTracks = mangaTracks.filter { it.trackerId in supportedTrackerIds } - supportedTrackerTracks.size to supportedTrackers.isNotEmpty() - } - .distinctUntilChanged() - .collectLatest { (trackingCount, hasLoggedInTrackers) -> - updateSuccessState { - it.copy( - trackingCount = trackingCount, - hasLoggedInTrackers = hasLoggedInTrackers, - ) - } - } - } - } - - // Track sheet - end - sealed interface Dialog { data class ChangeCategory( val manga: Manga, @@ -1067,28 +1021,28 @@ class MangaViewModel( } fun dismissDialog() { - updateSuccessState { it.copy(dialog = null) } + dialog.value = null } fun showDeleteChapterDialog(chapters: List) { - updateSuccessState { it.copy(dialog = Dialog.DeleteChapters(chapters)) } + dialog.value = Dialog.DeleteChapters(chapters) } fun showSettingsDialog() { - updateSuccessState { it.copy(dialog = Dialog.SettingsSheet) } + dialog.value = Dialog.SettingsSheet } fun showTrackDialog() { - updateSuccessState { it.copy(dialog = Dialog.TrackSheet) } + dialog.value = Dialog.TrackSheet } fun showCoverDialog() { - updateSuccessState { it.copy(dialog = Dialog.FullCover) } + dialog.value = Dialog.FullCover } fun showMigrateDialog(duplicate: Manga) { val manga = successState?.manga ?: return - updateSuccessState { it.copy(dialog = Dialog.Migrate(target = manga, current = duplicate)) } + dialog.value = Dialog.Migrate(target = manga, current = duplicate) } fun setExcludedScanlators(excludedScanlators: Set) { @@ -1097,6 +1051,10 @@ 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 @@ -1113,7 +1071,6 @@ class MangaViewModel( val hasLoggedInTrackers: Boolean = false, val isRefreshingData: Boolean = false, val dialog: Dialog? = null, - val hasPromptedToAddBefore: Boolean = false, val hideMissingChapters: Boolean = false, ) : State { val processedChapters by lazy {