Update page state management in reader

This commit is contained in:
Koitharu
2025-04-06 16:26:13 +03:00
parent d35a0c5e1e
commit ddecc72de7
21 changed files with 365 additions and 432 deletions

View File

@@ -61,6 +61,8 @@ inline fun <T, R> Flow<List<T>>.mapItems(crossinline transform: (T) -> R): Flow<
return map { list -> list.map(transform) }
}
fun <T> Flow<T>.throttle(timeoutMillis: Long): Flow<T> = throttle { timeoutMillis }
fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L
return transformLatest { value ->

View File

@@ -105,7 +105,7 @@ class ReaderActivity :
super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(true, false)
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(this, this)
scrollTimer = scrollTimerFactory.create(this, this)
pageSaveHelper = pageSaveHelperFactory.create(this)
@@ -146,7 +146,7 @@ class ReaderActivity :
.setAnchorView(viewBinding.toolbarDocked)
.show()
}
viewModel.readerSettings.observe(this) {
viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
}
viewModel.isZoomControlsEnabled.observe(this) {

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
@@ -85,6 +86,7 @@ class ReaderViewModel @Inject constructor(
interactor: DetailsInteractor,
deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler,
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
) : ChaptersPagesViewModel(
settings = settings,
interactor = interactor,
@@ -170,12 +172,8 @@ class ReaderViewModel @Inject constructor(
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val readerSettings = ReaderSettings(
parentScope = viewModelScope,
settings = settings,
colorFilterFlow = manga.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
val readerSettingsProducer = readerSettingsProducerFactory.create(
manga.mapNotNull { it?.id },
)
val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }

View File

@@ -1,66 +1,69 @@
package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import android.graphics.Bitmap
import android.view.View
import androidx.annotation.CheckResult
import androidx.lifecycle.MediatorLiveData
import androidx.collection.scatterSetOf
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import kotlinx.coroutines.CoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderBackground
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.MediatorStateFlow
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
class ReaderSettings(
private val parentScope: CoroutineScope,
private val settings: AppSettings,
private val colorFilterFlow: StateFlow<ReaderColorFilter?>,
) : MediatorLiveData<ReaderSettings>() {
data class ReaderSettings(
val zoomMode: ZoomMode,
val background: ReaderBackground,
val colorFilter: ReaderColorFilter?,
val isReaderOptimizationEnabled: Boolean,
val bitmapConfig: Bitmap.Config,
val isPagesNumbersEnabled: Boolean,
val isPagesCropEnabledStandard: Boolean,
val isPagesCropEnabledWebtoon: Boolean,
) {
private val internalObserver = InternalObserver()
private var collectJob: Job? = null
val zoomMode: ZoomMode
get() = settings.zoomMode
val background: ReaderBackground
get() = settings.readerBackground
val colorFilter: ReaderColorFilter?
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
val isReaderOptimizationEnabled: Boolean
get() = settings.isReaderOptimizationEnabled
val bitmapConfig: Bitmap.Config
get() = if (settings.is32BitColorsEnabled) {
private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
zoomMode = settings.zoomMode,
background = settings.readerBackground,
colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
bitmapConfig = if (settings.is32BitColorsEnabled) {
Bitmap.Config.ARGB_8888
} else {
Bitmap.Config.RGB_565
}
val isPagesNumbersEnabled: Boolean
get() = settings.isPagesNumbersEnabled
},
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
)
fun applyBackground(view: View) {
view.background = background.resolve(view.context)
}
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled(
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD,
)
fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
isPagesCropEnabledWebtoon
} else {
isPagesCropEnabledStandard
}
@CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
@@ -78,33 +81,13 @@ class ReaderSettings(
}
}
override fun onInactive() {
super.onInactive()
settings.unsubscribe(internalObserver)
collectJob?.cancel()
collectJob = null
}
class Producer @AssistedInject constructor(
@Assisted private val mangaId: Flow<Long>,
private val settings: AppSettings,
private val mangaDataRepository: MangaDataRepository,
) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
override fun onActive() {
super.onActive()
settings.subscribe(internalObserver)
collectJob?.cancel()
collectJob = parentScope.launch {
colorFilterFlow.collect(internalObserver)
}
}
override fun getValue() = this
private fun notifyChanged() {
value = value
}
private inner class InternalObserver :
FlowCollector<ReaderColorFilter?>,
SharedPreferences.OnSharedPreferenceChangeListener {
private val settingsKeys = setOf(
private val settingsKeys = scatterSetOf(
AppSettings.KEY_ZOOM_MODE,
AppSettings.KEY_PAGES_NUMBERS,
AppSettings.KEY_READER_BACKGROUND,
@@ -114,18 +97,38 @@ class ReaderSettings(
AppSettings.KEY_CF_BRIGHTNESS,
AppSettings.KEY_CF_INVERTED,
AppSettings.KEY_CF_GRAYSCALE,
AppSettings.KEY_READER_CROP,
)
private var job: Job? = null
override suspend fun emit(value: ReaderColorFilter?) {
withContext(Dispatchers.Main.immediate) {
notifyChanged()
override fun onActive() {
assert(job?.isActive != true)
job?.cancel()
job = processLifecycleScope.launch(Dispatchers.Default) {
observeImpl()
}
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key in settingsKeys) {
notifyChanged()
override fun onInactive() {
job?.cancel()
job = null
}
private suspend fun observeImpl() {
combine(
mangaId.flatMapLatest { mangaDataRepository.observeColorFilter(it) },
settings.observe().filter { x -> x == null || x in settingsKeys }.onStart { emit(null) },
) { mangaCf, settingsKey ->
ReaderSettings(settings, mangaCf)
}.collect {
publishValue(it)
}
}
@AssistedFactory
interface Factory {
fun create(mangaId: Flow<Long>): Producer
}
}
}

View File

@@ -1,40 +1,56 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context
import android.view.View
import androidx.annotation.CallSuper
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.PageHolderDelegate.State
import org.koitharu.kotatsu.reader.ui.pager.vm.PageState
import org.koitharu.kotatsu.reader.ui.pager.vm.PageViewModel
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
loader: PageLoader,
protected val settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback {
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(
protected val viewModel = PageViewModel(
loader = loader,
readerSettings = settings,
callback = this,
settingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
isWebtoon = this is WebtoonHolder,
)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
protected abstract val ssiv: SubsamplingScaleImageView
protected val settings: ReaderSettings
get() = viewModel.settingsProducer.value
val context: Context
get() = itemView.context
@@ -42,51 +58,128 @@ abstract class BasePageHolder<B : ViewBinding>(
var boundData: ReaderPage? = null
private set
override fun onConfigChanged() {
settings.applyBackground(itemView)
init {
lifecycleScope.launch(Dispatchers.Main) {
ssiv.bindToLifecycle(this@BasePageHolder)
ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
ssiv.addOnImageEventListener(viewModel)
ssiv.addOnImageEventListener(this@BasePageHolder)
}
val clickListener = View.OnClickListener { v ->
when (v.id) {
R.id.button_retry -> viewModel.retry(
page = boundData?.toMangaPage() ?: return@OnClickListener,
isFromUser = true,
)
R.id.button_error_details -> viewModel.showErrorDetails(boundData?.url)
}
}
bindingInfo.buttonRetry.setOnClickListener(clickListener)
bindingInfo.buttonErrorDetails.setOnClickListener(clickListener)
}
fun requireData(): ReaderPage {
return checkNotNull(boundData) { "Calling requireData() before bind()" }
@CallSuper
protected open fun onConfigChanged(settings: ReaderSettings) {
settings.applyBackground(itemView)
if (viewModel.state.value is PageState.Shown) {
onReady()
}
}
fun reloadImage() {
val source = (viewModel.state.value as? PageState.Shown)?.source ?: return
ssiv.setImage(source)
}
fun bind(data: ReaderPage) {
boundData = data
viewModel.onBind(data.toMangaPage())
onBind(data)
}
protected abstract fun onBind(data: ReaderPage)
@CallSuper
protected open fun onBind(data: ReaderPage) = Unit
override fun onCreate() {
super.onCreate()
context.registerComponentCallbacks(delegate)
context.registerComponentCallbacks(viewModel)
viewModel.state.observe(this, ::onStateChanged)
viewModel.settingsProducer.observe(this, ::onConfigChanged)
}
override fun onResume() {
super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) {
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) }
ssiv.applyDownSampling(isForeground = true)
if (viewModel.state.value is PageState.Error && !viewModel.isLoading()) {
boundData?.let { viewModel.retry(it.toMangaPage(), isFromUser = false) }
}
}
override fun onPause() {
super.onPause()
ssiv.applyDownSampling(isForeground = false)
}
override fun onDestroy() {
context.unregisterComponentCallbacks(delegate)
context.unregisterComponentCallbacks(viewModel)
super.onDestroy()
}
@CallSuper
open fun onAttachedToWindow() {
delegate.onAttachedToWindow()
}
open fun onAttachedToWindow() = Unit
@CallSuper
open fun onDetachedFromWindow() {
delegate.onDetachedFromWindow()
}
open fun onDetachedFromWindow() = Unit
@CallSuper
open fun onRecycled() {
delegate.onRecycle()
viewModel.onRecycle()
ssiv.recycle()
}
protected open fun onStateChanged(state: PageState) {
bindingInfo.layoutError.isVisible = state is PageState.Error
bindingInfo.progressBar.showOrHide(!state.isFinalState())
bindingInfo.textViewStatus.isGone = state.isFinalState()
val progress = (state as? PageState.Loading)?.progress ?: -1
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
} else {
bindingInfo.progressBar.isIndeterminate = true
bindingInfo.textViewStatus.setText(R.string.loading_)
}
when (state) {
is PageState.Converting -> {
bindingInfo.textViewStatus.setText(R.string.processing_)
}
is PageState.Empty -> Unit
is PageState.Error -> {
val e = state.error
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
is PageState.Loaded -> {
bindingInfo.textViewStatus.setText(R.string.processing_)
ssiv.setImage(state.source)
}
is PageState.Loading -> {
if (state.preview != null && ssiv.getState() == null) {
ssiv.setImage(state.preview)
}
}
is PageState.Shown -> Unit
}
}
protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {

View File

@@ -142,7 +142,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -15,7 +15,7 @@ import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val readerSettingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() {
@@ -58,7 +58,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
): H = onCreateViewHolder(parent, loader, readerSettingsProducer, networkState, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
differ.submitList(items) {
@@ -69,7 +69,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
protected abstract fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
): H

View File

@@ -17,10 +17,17 @@ class DoublePageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
private val isEven: Boolean
get() = bindingAdapterPosition and 1 == 0
@@ -35,7 +42,7 @@ class DoublePageHolder(
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class DoublePagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<DoublePageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = DoublePageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -90,7 +90,7 @@ open class DoubleReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding
override fun onCreateAdapter() = DoublePagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -17,17 +17,24 @@ class ReversedPageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
) : PageHolder(
owner = owner,
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = Gravity.START or Gravity.BOTTOM
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),

View File

@@ -13,22 +13,22 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<ReversedPageHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = ReversedPageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -19,7 +19,7 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
override fun onCreateAdapter() = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -2,23 +2,15 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint
import android.graphics.PointF
import android.view.View
import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
@@ -28,37 +20,25 @@ open class PageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener,
) : BasePageHolder<ItemPageBinding>(
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
),
ZoomControl.ZoomControlListener {
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.isEagerLoadingEnabled = !context.isLowRamDevice()
binding.ssiv.addOnImageEventListener(delegate)
@Suppress("LeakingThis")
bindingInfo.buttonRetry.setOnClickListener(this)
@Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this)
}
override val ssiv = binding.ssiv
override fun onResume() {
super.onResume()
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
override fun onConfigChanged(settings: ReaderSettings) {
super.onConfigChanged(settings)
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
reloadImage()
}
binding.ssiv.applyDownSampling(isResumed())
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
@@ -66,7 +46,7 @@ open class PageHolder(
@SuppressLint("SetTextI18n")
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
super.onBind(data)
binding.textViewNumber.text = (data.index + 1).toString()
}
@@ -75,33 +55,7 @@ open class PageHolder(
binding.ssiv.recycle()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
bindingInfo.textViewStatus.setTextAndVisible(R.string.loading_)
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
} else {
bindingInfo.progressBar.isIndeterminate = true
bindingInfo.textViewStatus.setText(R.string.loading_)
}
}
override fun onPreviewReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.toFloat(),
@@ -141,34 +95,6 @@ open class PageHolder(
}
}
override fun onImageShown(isPreview: Boolean) {
if (!isPreview) {
bindingInfo.progressBar.hide()
}
bindingInfo.textViewStatus.isVisible = false
}
override fun onTrimMemory() {
// TODO https://developer.android.com/topic/performance/memory
}
final override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
override fun onZoomIn() {
scaleBy(1.2f)
}

View File

@@ -13,22 +13,27 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<PageHolder>(
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = PageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.reader.ui.pager.vm
import com.davemorrissey.labs.subscaleview.ImageSource
sealed class PageState {
data object Empty : PageState()
data class Loading(
val preview: ImageSource?,
val progress: Int,
) : PageState()
data class Loaded(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
class Converting() : PageState()
data class Shown(
val source: ImageSource,
val isConverted: Boolean,
) : PageState()
data class Error(
val error: Throwable,
) : PageState()
fun isFinalState(): Boolean = this is Error || this is Shown
}

View File

@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.reader.ui.pager
package org.koitharu.kotatsu.reader.ui.pager.vm
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import android.graphics.Rect
import android.net.Uri
import androidx.lifecycle.Observer
import androidx.annotation.WorkerThread
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.ImageSource
import kotlinx.coroutines.CancellationException
@@ -14,41 +14,38 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext
import okio.IOException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.throttle
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import java.io.IOException
class PageHolderDelegate(
class PageViewModel(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val callback: Callback,
val settingsProducer: ReaderSettings.Producer,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
private val isWebtoon: Boolean,
) : DefaultOnImageEventListener, Observer<ReaderSettings>, ComponentCallbacks2 {
) : DefaultOnImageEventListener, ComponentCallbacks2 {
private val scope = loader.loaderScope + Dispatchers.Main.immediate
var state = State.EMPTY
private set
private var job: Job? = null
private var uri: Uri? = null
private var cachedBounds: Rect? = null
private var error: Throwable? = null
val state = MutableStateFlow<PageState>(PageState.Empty)
init {
scope.launch(Dispatchers.Main) { // the same as post() -- wait until child fields init
callback.onConfigChanged()
// callback.onConfigChanged()
}
}
@@ -56,7 +53,7 @@ class PageHolderDelegate(
fun onBind(page: MangaPage) {
val prevJob = job
job = scope.launch {
job = scope.launch(Dispatchers.Default) {
prevJob?.cancelAndJoin()
doLoad(page, force = false)
}
@@ -66,8 +63,8 @@ class PageHolderDelegate(
val prevJob = job
job = scope.launch {
prevJob?.cancelAndJoin()
val e = error
if (e != null && ExceptionResolver.canResolve(e)) {
val e = (state.value as? PageState.Error)?.error
if (e != null && ExceptionResolver.Companion.canResolve(e)) {
if (!isFromUser) {
return@launch
}
@@ -78,75 +75,38 @@ class PageHolderDelegate(
}
fun showErrorDetails(url: String?) {
val e = error ?: return
val e = (state.value as? PageState.Error)?.error ?: return
exceptionResolver.showErrorDetails(e, url)
}
fun onAttachedToWindow() {
readerSettings.observeForever(this)
}
fun onDetachedFromWindow() {
readerSettings.removeObserver(this)
}
fun onRecycle() {
state = State.EMPTY
uri = null
state.value = PageState.Empty
cachedBounds = null
error = null
job?.cancel()
}
fun reload() {
if (state == State.SHOWN) {
uri?.let {
callback.onImageReady(it.toImageSource(cachedBounds))
}
}
}
override fun onReady() {
if (state >= State.LOADED) {
state = State.SHOWING
error = null
callback.onImageShowing(readerSettings, isPreview = false)
} else if (state == State.LOADING_WITH_PREVIEW) {
callback.onImageShowing(readerSettings, isPreview = true)
}
}
override fun onImageLoaded() {
if (state >= State.LOADED) {
state = State.SHOWN
error = null
callback.onImageShown(isPreview = false)
} else if (state == State.LOADING_WITH_PREVIEW) {
callback.onImageShown(isPreview = true)
state.update {
if (it is PageState.Loaded) PageState.Shown(it.source, it.isConverted) else it
}
}
override fun onImageLoadError(e: Throwable) {
e.printStackTraceDebug()
if (state < State.LOADED) {
// ignore preview error
return
}
val uri = this.uri
error = e
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
tryConvert(uri, e)
} else {
state = State.ERROR
callback.onError(e)
}
}
override fun onChanged(value: ReaderSettings) {
if (state == State.SHOWN) {
callback.onImageShowing(readerSettings, isPreview = false)
state.update { currentState ->
if (currentState is PageState.Loaded) {
val uri = (currentState.source as? ImageSource.Uri)?.uri
if (!currentState.isConverted && uri != null && e is IOException) {
tryConvert(uri, e)
PageState.Converting()
} else {
PageState.Error(e)
}
} else {
currentState
}
}
callback.onConfigChanged()
}
override fun onConfigurationChanged(newConfig: Configuration) = Unit
@@ -155,68 +115,58 @@ class PageHolderDelegate(
override fun onLowMemory() = Unit
override fun onTrimMemory(level: Int) {
callback.onTrimMemory()
// callback.onTrimMemory()
}
private fun tryConvert(uri: Uri, e: Exception) {
val prevJob = job
job = scope.launch {
prevJob?.join()
state = State.CONVERTING
state.value = PageState.Converting()
try {
val newUri = loader.convertBimap(uri)
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(newUri)
} else {
null
}
state = State.CONVERTED
callback.onImageReady(newUri.toImageSource(cachedBounds))
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = true)
} catch (ce: CancellationException) {
throw ce
} catch (e2: Throwable) {
e2.printStackTrace()
e.addSuppressed(e2)
state = State.ERROR
callback.onError(e)
state.value = PageState.Error(e)
}
}
}
@WorkerThread
private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
state = State.LOADING
error = null
callback.onLoadingStarted()
state.value = PageState.Loading(null/* TODO */, -1)
val previewJob = launch {
val preview = loader.loadPreview(data)
if (preview != null && state == State.LOADING) {
state = State.LOADING_WITH_PREVIEW
callback.onPreviewReady(preview)
val preview = loader.loadPreview(data) ?: return@launch
state.update {
if (it is PageState.Loading) it.copy(preview = preview) else it
}
}
try {
val task = withContext(Dispatchers.Default) {
loader.loadPageAsync(data, force)
}
val task = loader.loadPageAsync(data, force)
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
val uri = task.await()
progressObserver.cancelAndJoin()
previewJob.cancel()
uri = file
state = State.LOADED
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(checkNotNull(uri))
cachedBounds = if (settingsProducer.value.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(uri)
} else {
null
}
callback.onImageReady(checkNotNull(uri).toImageSource(cachedBounds))
state.value = PageState.Loaded(uri.toImageSource(cachedBounds), isConverted = false)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR
error = e
callback.onError(e)
state.value = PageState.Error(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data, isFromUser = false)
@@ -225,9 +175,17 @@ class PageHolderDelegate(
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(250)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)
.throttle(250)
.onEach {
val progressValue = (100 * it).toInt()
state.update { currentState ->
if (currentState is PageState.Loading) {
currentState.copy(progress = progressValue)
} else {
currentState
}
}
}.launchIn(scope)
private fun Uri.toImageSource(bounds: Rect?): ImageSource {
val source = ImageSource.uri(this)
@@ -237,29 +195,4 @@ class PageHolderDelegate(
source
}
}
enum class State {
EMPTY, LOADING, LOADING_WITH_PREVIEW, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
}
interface Callback {
fun onLoadingStarted()
fun onError(e: Throwable)
fun onPreviewReady(source: ImageSource)
fun onImageReady(source: ImageSource)
fun onImageShowing(settings: ReaderSettings, isPreview: Boolean)
fun onImageShown(isPreview: Boolean)
fun onProgressChanged(progress: Int)
fun onConfigChanged()
fun onTrimMemory()
}
}

View File

@@ -13,15 +13,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
) : BaseReaderAdapter<WebtoonHolder>(loader, readerSettingsProducer, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = WebtoonHolder(
@@ -32,7 +32,7 @@ class WebtoonAdapter(
false,
),
loader = loader,
settings = settings,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -1,65 +1,43 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.isSerializable
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
class WebtoonHolder(
owner: LifecycleOwner,
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: ReaderSettings,
readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner),
View.OnClickListener {
) : BasePageHolder<ItemPageWebtoonBinding>(
binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
) {
override val ssiv = binding.ssiv
private var scrollToRestore = 0
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init {
binding.ssiv.bindToLifecycle(owner)
binding.ssiv.addOnImageEventListener(delegate)
bindingInfo.buttonRetry.setOnClickListener(this)
bindingInfo.buttonErrorDetails.setOnClickListener(this)
}
override fun onResume() {
super.onResume()
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
override fun onConfigChanged(settings: ReaderSettings) {
super.onConfigChanged(settings)
if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload()
reloadImage()
}
binding.ssiv.applyDownSampling(isResumed())
}
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}
override fun onRecycled() {
super.onRecycled()
binding.ssiv.recycle()
@@ -75,31 +53,7 @@ class WebtoonHolder(
goneOnInvisibleListener.detach()
}
override fun onLoadingStarted() {
bindingInfo.layoutError.isVisible = false
bindingInfo.progressBar.show()
binding.ssiv.recycle()
bindingInfo.textViewStatus.setTextAndVisible(R.string.loading_)
}
override fun onProgressChanged(progress: Int) {
if (progress in 0..100) {
bindingInfo.progressBar.isIndeterminate = false
bindingInfo.progressBar.setProgressCompat(progress, true)
bindingInfo.textViewStatus.text = context.getString(R.string.percent_string_pattern, progress.toString())
} else {
bindingInfo.progressBar.isIndeterminate = true
bindingInfo.textViewStatus.setText(R.string.loading_)
}
}
override fun onPreviewReady(source: ImageSource) = Unit
override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
override fun onReady() {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) {
scrollTo(
@@ -113,32 +67,6 @@ class WebtoonHolder(
}
}
override fun onImageShown(isPreview: Boolean) {
bindingInfo.progressBar.hide()
bindingInfo.textViewStatus.isVisible = false
}
override fun onTrimMemory() {
// TODO
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_retry -> delegate.retry(boundData?.toMangaPage() ?: return, isFromUser = true)
R.id.button_error_details -> delegate.showErrorDetails(boundData?.url)
}
}
override fun onError(e: Throwable) {
bindingInfo.textViewError.text = e.getDisplayMessage(context.resources)
bindingInfo.buttonRetry.setText(
ExceptionResolver.getResolveStringId(e).ifZero { R.string.try_again },
)
bindingInfo.buttonErrorDetails.isVisible = e.isSerializable()
bindingInfo.layoutError.isVisible = true
bindingInfo.progressBar.hide()
}
fun getScrollY() = binding.ssiv.getScroll()
fun restoreScroll(scroll: Int) {

View File

@@ -67,7 +67,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
rv.addItemDecoration(WebtoonGapsDecoration())
}
}
viewModel.readerSettings.observe(viewLifecycleOwner) {
viewModel.readerSettingsProducer.observe(viewLifecycleOwner) {
it.applyBackground(binding.root)
}
}
@@ -81,7 +81,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
override fun onCreateAdapter() = WebtoonAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
readerSettingsProducer = viewModel.readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
)

View File

@@ -31,7 +31,7 @@
<string name="add">Add</string>
<string name="save">Save</string>
<string name="share">Share</string>
<string name="create_shortcut">Create shortcut</string>
<string name="create_shortcut">Create shortcut</string>
<string name="share_s">Share %s</string>
<string name="search">Search</string>
<string name="search_manga">Search manga</string>