Experimental: improve manga loading in reader
This commit is contained in:
@@ -44,13 +44,13 @@ abstract class BaseViewModel : ViewModel() {
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start, block)
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start, block)
|
||||
|
||||
protected fun launchLoadingJob(
|
||||
context: CoroutineContext = EmptyCoroutineContext,
|
||||
start: CoroutineStart = CoroutineStart.DEFAULT,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = viewModelScope.launch(context + createErrorHandler(), start) {
|
||||
): Job = viewModelScope.launch(context.withDefaultExceptionHandler(), start) {
|
||||
loadingCounter.increment()
|
||||
try {
|
||||
block()
|
||||
@@ -81,15 +81,28 @@ abstract class BaseViewModel : ViewModel() {
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
private fun createErrorHandler() = CoroutineExceptionHandler { coroutineContext, throwable ->
|
||||
throwable.printStackTraceDebug()
|
||||
if (coroutineContext[SkipErrors.key] == null && throwable !is CancellationException) {
|
||||
errorEvent.call(throwable)
|
||||
private fun CoroutineContext.withDefaultExceptionHandler() =
|
||||
if (this[CoroutineExceptionHandler.Key] is EventExceptionHandler) {
|
||||
this
|
||||
} else {
|
||||
this + EventExceptionHandler(errorEvent)
|
||||
}
|
||||
}
|
||||
|
||||
protected object SkipErrors : AbstractCoroutineContextElement(Key) {
|
||||
|
||||
private object Key : CoroutineContext.Key<SkipErrors>
|
||||
}
|
||||
|
||||
protected class EventExceptionHandler(
|
||||
private val event: MutableEventFlow<Throwable>,
|
||||
) : AbstractCoroutineContextElement(CoroutineExceptionHandler),
|
||||
CoroutineExceptionHandler {
|
||||
|
||||
override fun handleException(context: CoroutineContext, exception: Throwable) {
|
||||
exception.printStackTraceDebug()
|
||||
if (context[SkipErrors.key] == null && exception !is CancellationException) {
|
||||
event.call(exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +1,30 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import androidx.collection.ArrayMap
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
open class MultiMutex<T : Any> : Set<T> {
|
||||
open class MultiMutex<T : Any> {
|
||||
|
||||
private val delegates = ArrayMap<T, Mutex>()
|
||||
private val delegates = ConcurrentHashMap<T, Mutex>()
|
||||
|
||||
override val size: Int
|
||||
get() = delegates.size
|
||||
@VisibleForTesting
|
||||
val size: Int
|
||||
get() = delegates.count { it.value.isLocked }
|
||||
|
||||
override fun contains(element: T): Boolean = synchronized(delegates) {
|
||||
delegates.containsKey(element)
|
||||
}
|
||||
fun isNotEmpty() = delegates.any { it.value.isLocked }
|
||||
|
||||
override fun containsAll(elements: Collection<T>): Boolean = synchronized(delegates) {
|
||||
elements.all { x -> delegates.containsKey(x) }
|
||||
}
|
||||
|
||||
override fun isEmpty(): Boolean = delegates.isEmpty()
|
||||
|
||||
override fun iterator(): Iterator<T> = synchronized(delegates) {
|
||||
delegates.keys.toList()
|
||||
}.iterator()
|
||||
|
||||
fun isLocked(element: T): Boolean = synchronized(delegates) {
|
||||
delegates[element]?.isLocked == true
|
||||
}
|
||||
|
||||
fun tryLock(element: T): Boolean {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
return mutex.tryLock()
|
||||
}
|
||||
fun isEmpty() = delegates.none { it.value.isLocked }
|
||||
|
||||
suspend fun lock(element: T) {
|
||||
val mutex = synchronized(delegates) {
|
||||
delegates.getOrPut(element, ::Mutex)
|
||||
}
|
||||
val mutex = delegates.computeIfAbsent(element) { Mutex() }
|
||||
mutex.lock()
|
||||
}
|
||||
|
||||
fun unlock(element: T) {
|
||||
synchronized(delegates) {
|
||||
delegates.remove(element)?.unlock()
|
||||
}
|
||||
delegates[element]?.unlock()
|
||||
}
|
||||
|
||||
suspend inline fun <R> withLock(element: T, block: () -> R): R {
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.transform
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
import kotlinx.coroutines.flow.transformWhile
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -17,6 +17,7 @@ data class MangaDetails(
|
||||
private val localManga: LocalManga?,
|
||||
private val override: MangaOverride?,
|
||||
val description: CharSequence?,
|
||||
@Deprecated("Caller should decide if manga is loaded enough by itself")
|
||||
val isLoaded: Boolean,
|
||||
) {
|
||||
|
||||
@@ -31,13 +32,12 @@ data class MangaDetails(
|
||||
val id: Long
|
||||
get() = manga.id
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> = manga.chapters?.groupBy { it.branch }.orEmpty()
|
||||
|
||||
val branches: Set<String?>
|
||||
get() = chapters.keys
|
||||
|
||||
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||
|
||||
val chapters: Map<String?, List<MangaChapter>> by lazy {
|
||||
allChapters.groupBy { it.branch }
|
||||
}
|
||||
|
||||
val isLocal
|
||||
get() = manga.isLocal
|
||||
|
||||
@@ -51,7 +51,22 @@ data class MangaDetails(
|
||||
.ifNullOrEmpty { localManga?.manga?.coverUrl }
|
||||
?.nullIfEmpty()
|
||||
|
||||
fun toManga() = manga.withOverride(override)
|
||||
private val mergedManga by lazy {
|
||||
if (localManga == null) {
|
||||
// fast path
|
||||
manga.withOverride(override)
|
||||
} else {
|
||||
manga.copy(
|
||||
title = override?.title.ifNullOrEmpty { manga.title },
|
||||
coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
largeCoverUrl = override?.coverUrl.ifNullOrEmpty { manga.largeCoverUrl },
|
||||
contentRating = override?.contentRating ?: manga.contentRating,
|
||||
chapters = allChapters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toManga() = mergedManga
|
||||
|
||||
fun getLocale(): Locale? {
|
||||
findAppropriateLocale(chapters.keys.singleOrNull())?.let {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.details.domain
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
|
||||
@@ -9,30 +9,33 @@ import androidx.core.text.parseAsHtml
|
||||
import coil3.request.CachePolicy
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.IOException
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.nav.MangaIntent
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.parser.CachingMangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.peek
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.ui.model.MangaOverride
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitize
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
|
||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.recoverNotNull
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
@@ -40,91 +43,115 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val recoverUseCase: RecoverMangaUseCase,
|
||||
private val imageGetter: Html.ImageGetter,
|
||||
private val newChaptersUseCaseProvider: Provider<CheckNewChaptersUseCase>,
|
||||
private val networkState: NetworkState,
|
||||
) {
|
||||
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = channelFlow {
|
||||
operator fun invoke(intent: MangaIntent, force: Boolean): Flow<MangaDetails> = flow {
|
||||
val manga = requireNotNull(mangaDataRepository.resolveIntent(intent, withChapters = true)) {
|
||||
"Cannot resolve intent $intent"
|
||||
}
|
||||
val override = mangaDataRepository.getOverride(manga.id)
|
||||
send(
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = null,
|
||||
description = manga.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
if (manga.isLocal) {
|
||||
val details = getDetails(manga, force)
|
||||
send(
|
||||
loadLocal(manga, override, force)
|
||||
} else {
|
||||
loadRemote(manga, override, force)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.buffer(Channel.UNLIMITED)
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
||||
/**
|
||||
* Load local manga + try to load the linked remote one if network is not restricted
|
||||
* Suppress any network errors
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadLocal(manga: Manga, override: MangaOverride?, force: Boolean) {
|
||||
val skipNetworkLoad = !force && networkState.isOfflineOrRestricted()
|
||||
val localDetails = localMangaRepository.getDetails(manga)
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localDetails.description?.parseAsHtml(withImages = false),
|
||||
isLoaded = skipNetworkLoad,
|
||||
),
|
||||
)
|
||||
if (skipNetworkLoad) {
|
||||
return
|
||||
}
|
||||
val remoteManga = localMangaRepository.getRemoteManga(manga)
|
||||
if (remoteManga == null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
manga = localDetails,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
description = localDetails.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
return@channelFlow
|
||||
}
|
||||
val local = async {
|
||||
localMangaRepository.findSavedManga(manga)
|
||||
}
|
||||
if (!force && networkState.isOfflineOrRestricted()) {
|
||||
// try to avoid loading if has saved manga
|
||||
val localManga = local.await()
|
||||
if (localManga != null) {
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = manga.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
return@channelFlow
|
||||
} else {
|
||||
val remoteDetails = getDetails(remoteManga, force).getOrNull()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails ?: remoteManga,
|
||||
localManga = LocalManga(localDetails),
|
||||
override = override,
|
||||
description = (remoteDetails ?: localDetails).description?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
if (remoteDetails != null) {
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val details = getDetails(manga, force)
|
||||
launch { mangaDataRepository.updateChapters(details) }
|
||||
launch { updateTracker(details) }
|
||||
send(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load remote manga + saved one if available
|
||||
* Throw network errors after loading local manga only
|
||||
*/
|
||||
private suspend fun FlowCollector<MangaDetails>.loadRemote(
|
||||
manga: Manga,
|
||||
override: MangaOverride?,
|
||||
force: Boolean
|
||||
) = coroutineScope {
|
||||
val remoteDeferred = async {
|
||||
getDetails(manga, force)
|
||||
}
|
||||
val localManga = localMangaRepository.findSavedManga(manga, withDetails = true)
|
||||
if (localManga != null) {
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local.peek(),
|
||||
manga = manga,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
description = localManga.manga.description?.parseAsHtml(withImages = true),
|
||||
isLoaded = false,
|
||||
),
|
||||
)
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = details,
|
||||
localManga = local.await(),
|
||||
override = override,
|
||||
description = details.description?.parseAsHtml(withImages = true)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
local.await()?.manga?.also { localManga ->
|
||||
send(
|
||||
MangaDetails(
|
||||
manga = localManga,
|
||||
localManga = null,
|
||||
override = override,
|
||||
description = localManga.description?.parseAsHtml(withImages = false)?.trim(),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
} ?: close(e)
|
||||
}
|
||||
val remoteDetails = remoteDeferred.await().getOrThrow()
|
||||
emit(
|
||||
MangaDetails(
|
||||
manga = remoteDetails,
|
||||
localManga = localManga,
|
||||
override = override,
|
||||
description = (remoteDetails.description
|
||||
?: localManga?.manga?.description)?.parseAsHtml(withImages = true),
|
||||
isLoaded = true,
|
||||
),
|
||||
)
|
||||
mangaDataRepository.updateChapters(remoteDetails)
|
||||
}
|
||||
|
||||
private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable {
|
||||
@@ -140,20 +167,18 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrThrow()
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? {
|
||||
return if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.takeUnless { it.isBlank() }
|
||||
}
|
||||
|
||||
private suspend fun String.parseAsHtml(withImages: Boolean): CharSequence? = if (withImages) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
parseAsHtml(imageGetter = imageGetter)
|
||||
}.filterSpans()
|
||||
} else {
|
||||
runInterruptible(Dispatchers.Default) {
|
||||
parseAsHtml()
|
||||
}.filterSpans().sanitize()
|
||||
}.trim().nullIfEmpty()
|
||||
|
||||
private fun Spanned.filterSpans(): Spanned {
|
||||
val spannable = SpannableString.valueOf(this)
|
||||
val spans = spannable.getSpans<ForegroundColorSpan>()
|
||||
@@ -162,10 +187,4 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
}
|
||||
return spannable
|
||||
}
|
||||
|
||||
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
|
||||
newChaptersUseCaseProvider.get()(details)
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.appcompat.widget.TooltipCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.google.android.material.slider.Slider
|
||||
@@ -50,7 +49,9 @@ class ReaderActionsView @JvmOverloads constructor(
|
||||
private val binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this)
|
||||
private val rotationObserver = object : ContentObserver(handler) {
|
||||
override fun onChange(selfChange: Boolean) {
|
||||
updateRotationButton()
|
||||
post {
|
||||
updateRotationButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var isSliderChanged = false
|
||||
|
||||
@@ -29,10 +29,15 @@ import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||
import org.koitharu.kotatsu.core.nav.router
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -133,7 +138,7 @@ class ReaderActivity :
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(
|
||||
viewModel.onLoadingError.observeEvent(
|
||||
this,
|
||||
DialogErrorObserver(
|
||||
host = viewBinding.container,
|
||||
@@ -148,13 +153,24 @@ class ReaderActivity :
|
||||
},
|
||||
),
|
||||
)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.container,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = null,
|
||||
),
|
||||
)
|
||||
viewModel.readerMode.observe(this, Lifecycle.State.STARTED, this::onInitReader)
|
||||
viewModel.onPageSaved.observeEvent(this, PagesSavedObserver(viewBinding.container))
|
||||
viewModel.uiState.zipWithPrevious().observe(this, this::onUiStateChanged)
|
||||
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.content.observe(this) {
|
||||
onLoadingStateChanged(viewModel.isLoading.value)
|
||||
}
|
||||
combine(
|
||||
viewModel.isLoading,
|
||||
viewModel.content.map { it.pages.isNotEmpty() }.distinctUntilChanged(),
|
||||
::Pair,
|
||||
).flowOn(Dispatchers.Default)
|
||||
.observe(this, this::onLoadingStateChanged)
|
||||
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
|
||||
viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it }
|
||||
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
|
||||
@@ -243,9 +259,14 @@ class ReaderActivity :
|
||||
viewBinding.timerControl.onReaderModeChanged(mode)
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val hasPages = viewModel.content.value.pages.isNotEmpty()
|
||||
viewBinding.layoutLoading.isVisible = isLoading && !hasPages
|
||||
private fun onLoadingStateChanged(value: Pair<Boolean, Boolean>) {
|
||||
val (isLoading, hasPages) = value
|
||||
val showLoadingLayout = isLoading && !hasPages
|
||||
if (viewBinding.layoutLoading.isVisible != showLoadingLayout) {
|
||||
val transition = Fade().addTarget(viewBinding.layoutLoading)
|
||||
TransitionManager.beginDelayedTransition(viewBinding.root, transition)
|
||||
viewBinding.layoutLoading.isVisible = showLoadingLayout
|
||||
}
|
||||
if (isLoading && hasPages) {
|
||||
viewBinding.toastView.show(R.string.loading_)
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package org.koitharu.kotatsu.reader.ui
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
@@ -57,6 +59,7 @@ import org.koitharu.kotatsu.parsers.model.ContentRating
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.parsers.util.sizeOrZero
|
||||
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
|
||||
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
|
||||
@@ -108,13 +111,12 @@ class ReaderViewModel @Inject constructor(
|
||||
private var stateChangeJob: Job? = null
|
||||
|
||||
init {
|
||||
selectedBranch.value = savedStateHandle.get<String>(ReaderIntent.EXTRA_BRANCH)
|
||||
readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE]
|
||||
mangaDetails.value = intent.manga?.let { MangaDetails(it) }
|
||||
}
|
||||
|
||||
val readerMode = MutableStateFlow<ReaderMode?>(null)
|
||||
val onPageSaved = MutableEventFlow<Collection<Uri>>()
|
||||
val onLoadingError = MutableEventFlow<Throwable>()
|
||||
val onShowToast = MutableEventFlow<Int>()
|
||||
val onAskNsfwIncognito = MutableEventFlow<Unit>()
|
||||
val uiState = MutableStateFlow<ReaderUiState?>(null)
|
||||
@@ -393,31 +395,56 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
private fun loadImpl() {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val details = detailsLoadUseCase.invoke(intent, force = false).first { x -> x.isLoaded }
|
||||
mangaDetails.value = details
|
||||
chaptersLoader.init(details)
|
||||
val manga = details.toManga()
|
||||
// obtain state
|
||||
if (readingState.value == null) {
|
||||
readingState.value = getStateFromIntent(manga)
|
||||
}
|
||||
val mode = detectReaderModeUseCase.invoke(manga, readingState.value)
|
||||
val branch = chaptersLoader.peekChapter(readingState.value?.chapterId ?: 0L)?.branch
|
||||
selectedBranch.value = branch
|
||||
mangaDetails.value = details.filterChapters(branch)
|
||||
readerMode.value = mode
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default + EventExceptionHandler(onLoadingError)) {
|
||||
val exception = try {
|
||||
detailsLoadUseCase(intent, force = false)
|
||||
.collect { details ->
|
||||
if (mangaDetails.value == null) {
|
||||
mangaDetails.value = details
|
||||
}
|
||||
chaptersLoader.init(details)
|
||||
val manga = details.toManga()
|
||||
// obtain state
|
||||
if (readingState.value == null) {
|
||||
val newState = getStateFromIntent(manga)
|
||||
if (newState == null) {
|
||||
return@collect // manga not loaded yet if cannot get state
|
||||
}
|
||||
readingState.value = newState
|
||||
val mode = runCatchingCancellable {
|
||||
detectReaderModeUseCase(manga, newState)
|
||||
}.getOrDefault(settings.defaultReaderMode)
|
||||
val branch = chaptersLoader.peekChapter(newState.chapterId)?.branch
|
||||
selectedBranch.value = branch
|
||||
readerMode.value = mode
|
||||
chaptersLoader.loadSingleChapter(newState.chapterId)
|
||||
}
|
||||
mangaDetails.value = details.filterChapters(selectedBranch.value)
|
||||
|
||||
chaptersLoader.loadSingleChapter(requireNotNull(readingState.value).chapterId)
|
||||
// save state
|
||||
if (!isIncognitoMode.firstNotNull()) {
|
||||
readingState.value?.let {
|
||||
val percent = computePercent(it.chapterId, it.page)
|
||||
historyUpdateUseCase.invoke(manga, it, percent)
|
||||
}
|
||||
// save state
|
||||
if (!isIncognitoMode.firstNotNull()) {
|
||||
readingState.value?.let {
|
||||
val percent = computePercent(it.chapterId, it.page)
|
||||
historyUpdateUseCase(manga, it, percent)
|
||||
}
|
||||
}
|
||||
notifyStateChanged()
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value)
|
||||
}
|
||||
null // no errors
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e
|
||||
}
|
||||
if (readingState.value == null) {
|
||||
onLoadingError.call(
|
||||
exception ?: IllegalStateException("Unable to load manga. This should never happen. Please report"),
|
||||
)
|
||||
} else if (exception != null) {
|
||||
// manga has been loaded but error occurred
|
||||
errorEvent.call(exception)
|
||||
}
|
||||
notifyStateChanged()
|
||||
content.value = ReaderContent(chaptersLoader.snapshot(), readingState.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,18 +540,40 @@ class ReaderViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getStateFromIntent(manga: Manga): ReaderState {
|
||||
val history = historyRepository.getOne(manga)
|
||||
val preselectedBranch = selectedBranch.value
|
||||
val result = if (history != null) {
|
||||
if (preselectedBranch != null && preselectedBranch != manga.findChapterById(history.chapterId)?.branch) {
|
||||
private suspend fun getStateFromIntent(manga: Manga): ReaderState? {
|
||||
// check if we have at least some chapters loaded
|
||||
if (manga.chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
// specific state is requested
|
||||
val requestedState: ReaderState? = savedStateHandle[ReaderIntent.EXTRA_STATE]
|
||||
if (requestedState != null) {
|
||||
return if (manga.findChapterById(requestedState.chapterId) != null) {
|
||||
requestedState
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val requestedBranch: String? = savedStateHandle[ReaderIntent.EXTRA_BRANCH]
|
||||
// continue reading
|
||||
val history = historyRepository.getOne(manga)
|
||||
if (history != null) {
|
||||
val chapter = manga.findChapterById(history.chapterId) ?: return null
|
||||
// specified branch is requested
|
||||
return if (ReaderIntent.EXTRA_BRANCH in savedStateHandle) {
|
||||
if (chapter.branch == requestedBranch) {
|
||||
ReaderState(history)
|
||||
} else {
|
||||
ReaderState(manga, requestedBranch)
|
||||
}
|
||||
} else {
|
||||
ReaderState(history)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return result ?: ReaderState(manga, preselectedBranch ?: manga.getPreferredBranch(null))
|
||||
|
||||
// start from beginning
|
||||
val preferredBranch = requestedBranch ?: manga.getPreferredBranch(null)
|
||||
return ReaderState(manga, preferredBranch)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class CheckNewChaptersUseCase @Inject constructor(
|
||||
|
||||
suspend operator fun invoke(manga: Manga): MangaUpdates = mutex.withLock(manga.id) {
|
||||
repository.updateTracks()
|
||||
val tracking = repository.getTrackOrNull(manga) ?: return MangaUpdates.Failure(
|
||||
val tracking = repository.getTrackOrNull(manga) ?: return@withLock MangaUpdates.Failure(
|
||||
manga = manga,
|
||||
error = null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user