Make MangaViewModel collect the entry only while subscribed

This commit is contained in:
AntsyLich
2026-10-07 19:59:51 +06:00
parent 9a6dea804d
commit 7ff97761b9
@@ -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<MangaViewModel.State>
field = MutableStateFlow<MangaViewModel.State>(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<Int> = arrayOf(-1, -1) // first and last selected index in list
private val selectedChapterIds: HashSet<Long> = HashSet()
private val selectedChapterIds = MutableStateFlow(emptySet<Long>())
/**
* 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<Dialog?>(null)
private val isRefreshingData = MutableStateFlow(false)
private val downloadStates = MutableStateFlow(emptyMap<Long, DownloadProgress>())
// 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<State> = 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<State.Success>().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<Chapter>,
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[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..<selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
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[0]
selectedPositions[0] = selectedIndex
} else if (selectedIndex > selectedPositions[1]) {
range = (selectedPositions[1] + 1)..<selectedIndex
selectedPositions[1] = selectedIndex
} else {
// Just select itself
range = IntRange.EMPTY
}
range.forEach {
val inbetweenItem = get(it)
if (!inbetweenItem.selected) {
selectedChapterIds.add(inbetweenItem.id)
set(it, inbetweenItem.copy(selected = true))
}
}
}
} else if (!fromLongPress) {
if (!selected) {
if (selectedIndex == selectedPositions[0]) {
selectedPositions[0] = indexOfFirst { it.selected }
} else if (selectedIndex == selectedPositions[1]) {
selectedPositions[1] = indexOfLast { it.selected }
}
} else {
if (selectedIndex < selectedPositions[0]) {
selectedPositions[0] = selectedIndex
} else if (selectedIndex > 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<Chapter>) {
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<String>) {
@@ -1097,6 +1051,10 @@ 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
@@ -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 {