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) } 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> { fun <T> Flow<T>.throttle(timeoutMillis: (T) -> Long): Flow<T> {
var lastEmittedAt = 0L var lastEmittedAt = 0L
return transformLatest { value -> return transformLatest { value ->

View File

@@ -105,7 +105,7 @@ class ReaderActivity :
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater)) setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings)
setDisplayHomeAsUp(true, false) setDisplayHomeAsUp(isEnabled = true, showUpAsClose = false)
touchHelper = TapGridDispatcher(this, this) touchHelper = TapGridDispatcher(this, this)
scrollTimer = scrollTimerFactory.create(this, this) scrollTimer = scrollTimerFactory.create(this, this)
pageSaveHelper = pageSaveHelperFactory.create(this) pageSaveHelper = pageSaveHelperFactory.create(this)
@@ -146,7 +146,7 @@ class ReaderActivity :
.setAnchorView(viewBinding.toolbarDocked) .setAnchorView(viewBinding.toolbarDocked)
.show() .show()
} }
viewModel.readerSettings.observe(this) { viewModel.readerSettingsProducer.observe(this) {
viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this)) viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this))
} }
viewModel.isZoomControlsEnabled.observe(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.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -85,6 +86,7 @@ class ReaderViewModel @Inject constructor(
interactor: DetailsInteractor, interactor: DetailsInteractor,
deleteLocalMangaUseCase: DeleteLocalMangaUseCase, deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
readerSettingsProducerFactory: ReaderSettings.Producer.Factory,
) : ChaptersPagesViewModel( ) : ChaptersPagesViewModel(
settings = settings, settings = settings,
interactor = interactor, interactor = interactor,
@@ -170,12 +172,8 @@ class ReaderViewModel @Inject constructor(
} }
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, false)
val readerSettings = ReaderSettings( val readerSettingsProducer = readerSettingsProducerFactory.create(
parentScope = viewModelScope, manga.mapNotNull { it?.id },
settings = settings,
colorFilterFlow = manga.flatMapLatest {
if (it == null) flowOf(null) else dataRepository.observeColorFilter(it.id)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null),
) )
val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT } val isMangaNsfw = manga.map { it?.contentRating == ContentRating.ADULT }

View File

@@ -1,66 +1,69 @@
package org.koitharu.kotatsu.reader.ui.config package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.View import android.view.View
import androidx.annotation.CheckResult import androidx.annotation.CheckResult
import androidx.lifecycle.MediatorLiveData import androidx.collection.scatterSetOf
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaImageDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder import com.davemorrissey.labs.subscaleview.decoder.SkiaImageRegionDecoder
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder 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.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow 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.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode 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.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderBackground
import org.koitharu.kotatsu.core.prefs.ReaderMode 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.isLowRamDevice
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
class ReaderSettings( data class ReaderSettings(
private val parentScope: CoroutineScope, val zoomMode: ZoomMode,
private val settings: AppSettings, val background: ReaderBackground,
private val colorFilterFlow: StateFlow<ReaderColorFilter?>, val colorFilter: ReaderColorFilter?,
) : MediatorLiveData<ReaderSettings>() { val isReaderOptimizationEnabled: Boolean,
val bitmapConfig: Bitmap.Config,
val isPagesNumbersEnabled: Boolean,
val isPagesCropEnabledStandard: Boolean,
val isPagesCropEnabledWebtoon: Boolean,
) {
private val internalObserver = InternalObserver() private constructor(settings: AppSettings, colorFilterOverride: ReaderColorFilter?) : this(
private var collectJob: Job? = null zoomMode = settings.zoomMode,
background = settings.readerBackground,
val zoomMode: ZoomMode colorFilter = colorFilterOverride?.takeUnless { it.isEmpty } ?: settings.readerColorFilter,
get() = settings.zoomMode isReaderOptimizationEnabled = settings.isReaderOptimizationEnabled,
bitmapConfig = if (settings.is32BitColorsEnabled) {
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) {
Bitmap.Config.ARGB_8888 Bitmap.Config.ARGB_8888
} else { } else {
Bitmap.Config.RGB_565 Bitmap.Config.RGB_565
} },
isPagesNumbersEnabled = settings.isPagesNumbersEnabled,
val isPagesNumbersEnabled: Boolean isPagesCropEnabledStandard = settings.isPagesCropEnabled(ReaderMode.STANDARD),
get() = settings.isPagesNumbersEnabled isPagesCropEnabledWebtoon = settings.isPagesCropEnabled(ReaderMode.WEBTOON),
)
fun applyBackground(view: View) { fun applyBackground(view: View) {
view.background = background.resolve(view.context) view.background = background.resolve(view.context)
} }
fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled( fun isPagesCropEnabled(isWebtoon: Boolean) = if (isWebtoon) {
if (isWebtoon) ReaderMode.WEBTOON else ReaderMode.STANDARD, isPagesCropEnabledWebtoon
) } else {
isPagesCropEnabledStandard
}
@CheckResult @CheckResult
fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean { fun applyBitmapConfig(ssiv: SubsamplingScaleImageView): Boolean {
@@ -78,33 +81,13 @@ class ReaderSettings(
} }
} }
override fun onInactive() { class Producer @AssistedInject constructor(
super.onInactive() @Assisted private val mangaId: Flow<Long>,
settings.unsubscribe(internalObserver) private val settings: AppSettings,
collectJob?.cancel() private val mangaDataRepository: MangaDataRepository,
collectJob = null ) : MediatorStateFlow<ReaderSettings>(ReaderSettings(settings, null)) {
}
override fun onActive() { private val settingsKeys = scatterSetOf(
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(
AppSettings.KEY_ZOOM_MODE, AppSettings.KEY_ZOOM_MODE,
AppSettings.KEY_PAGES_NUMBERS, AppSettings.KEY_PAGES_NUMBERS,
AppSettings.KEY_READER_BACKGROUND, AppSettings.KEY_READER_BACKGROUND,
@@ -114,18 +97,38 @@ class ReaderSettings(
AppSettings.KEY_CF_BRIGHTNESS, AppSettings.KEY_CF_BRIGHTNESS,
AppSettings.KEY_CF_INVERTED, AppSettings.KEY_CF_INVERTED,
AppSettings.KEY_CF_GRAYSCALE, AppSettings.KEY_CF_GRAYSCALE,
AppSettings.KEY_READER_CROP,
) )
private var job: Job? = null
override suspend fun emit(value: ReaderColorFilter?) { override fun onActive() {
withContext(Dispatchers.Main.immediate) { assert(job?.isActive != true)
notifyChanged() job?.cancel()
job = processLifecycleScope.launch(Dispatchers.Default) {
observeImpl()
} }
} }
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onInactive() {
if (key in settingsKeys) { job?.cancel()
notifyChanged() 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 package org.koitharu.kotatsu.reader.ui.pager
import android.content.Context import android.content.Context
import android.view.View
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding import androidx.viewbinding.ViewBinding
import com.davemorrissey.labs.subscaleview.DefaultOnImageEventListener
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder 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.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.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings 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 import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonHolder
abstract class BasePageHolder<B : ViewBinding>( abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
protected val settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), PageHolderDelegate.Callback { ) : LifecycleAwareViewHolder(binding.root, lifecycleOwner), DefaultOnImageEventListener {
@Suppress("LeakingThis") protected val viewModel = PageViewModel(
protected val delegate = PageHolderDelegate(
loader = loader, loader = loader,
readerSettings = settings, settingsProducer = readerSettingsProducer,
callback = this,
networkState = networkState, networkState = networkState,
exceptionResolver = exceptionResolver, exceptionResolver = exceptionResolver,
isWebtoon = this is WebtoonHolder, isWebtoon = this is WebtoonHolder,
) )
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root) protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
protected abstract val ssiv: SubsamplingScaleImageView
protected val settings: ReaderSettings
get() = viewModel.settingsProducer.value
val context: Context val context: Context
get() = itemView.context get() = itemView.context
@@ -42,51 +58,128 @@ abstract class BasePageHolder<B : ViewBinding>(
var boundData: ReaderPage? = null var boundData: ReaderPage? = null
private set private set
override fun onConfigChanged() { init {
settings.applyBackground(itemView) 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 { @CallSuper
return checkNotNull(boundData) { "Calling requireData() before bind()" } 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) { fun bind(data: ReaderPage) {
boundData = data boundData = data
viewModel.onBind(data.toMangaPage())
onBind(data) onBind(data)
} }
protected abstract fun onBind(data: ReaderPage) @CallSuper
protected open fun onBind(data: ReaderPage) = Unit
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
context.registerComponentCallbacks(delegate) context.registerComponentCallbacks(viewModel)
viewModel.state.observe(this, ::onStateChanged)
viewModel.settingsProducer.observe(this, ::onConfigChanged)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (delegate.state == State.ERROR && !delegate.isLoading()) { ssiv.applyDownSampling(isForeground = true)
boundData?.let { delegate.retry(it.toMangaPage(), isFromUser = false) } 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() { override fun onDestroy() {
context.unregisterComponentCallbacks(delegate) context.unregisterComponentCallbacks(viewModel)
super.onDestroy() super.onDestroy()
} }
@CallSuper open fun onAttachedToWindow() = Unit
open fun onAttachedToWindow() {
delegate.onAttachedToWindow()
}
@CallSuper open fun onDetachedFromWindow() = Unit
open fun onDetachedFromWindow() {
delegate.onDetachedFromWindow()
}
@CallSuper @CallSuper
open fun onRecycled() { 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) { protected fun SubsamplingScaleImageView.applyDownSampling(isForeground: Boolean) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,23 +2,15 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.PointF import android.graphics.PointF
import android.view.View
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.ImageSource
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl 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.databinding.ItemPageBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
@@ -28,37 +20,25 @@ open class PageHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageBinding, binding: ItemPageBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver, owner), ) : BasePageHolder<ItemPageBinding>(
View.OnClickListener, binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
),
ZoomControl.ZoomControlListener { ZoomControl.ZoomControlListener {
init { override val ssiv = binding.ssiv
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 fun onResume() { override fun onConfigChanged(settings: ReaderSettings) {
super.onResume() super.onConfigChanged(settings)
binding.ssiv.applyDownSampling(isForeground = true)
}
override fun onPause() {
super.onPause()
binding.ssiv.applyDownSampling(isForeground = false)
}
override fun onConfigChanged() {
super.onConfigChanged()
if (settings.applyBitmapConfig(binding.ssiv)) { if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload() reloadImage()
} }
binding.ssiv.applyDownSampling(isResumed()) binding.ssiv.applyDownSampling(isResumed())
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
@@ -66,7 +46,7 @@ open class PageHolder(
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
override fun onBind(data: ReaderPage) { override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage()) super.onBind(data)
binding.textViewNumber.text = (data.index + 1).toString() binding.textViewNumber.text = (data.index + 1).toString()
} }
@@ -75,33 +55,7 @@ open class PageHolder(
binding.ssiv.recycle() binding.ssiv.recycle()
} }
override fun onLoadingStarted() { override fun onReady() {
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) {
binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.maxScale = 2f * maxOf(
binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.width / binding.ssiv.sWidth.toFloat(),
binding.ssiv.height / binding.ssiv.sHeight.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() { override fun onZoomIn() {
scaleBy(1.2f) scaleBy(1.2f)
} }

View File

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

View File

@@ -1,65 +1,43 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner 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.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.GoneOnInvisibleListener 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.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.parsers.util.ifZero
import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
class WebtoonHolder( class WebtoonHolder(
owner: LifecycleOwner, owner: LifecycleOwner,
binding: ItemPageWebtoonBinding, binding: ItemPageWebtoonBinding,
loader: PageLoader, loader: PageLoader,
settings: ReaderSettings, readerSettingsProducer: ReaderSettings.Producer,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver, owner), ) : BasePageHolder<ItemPageWebtoonBinding>(
View.OnClickListener { binding = binding,
loader = loader,
readerSettingsProducer = readerSettingsProducer,
networkState = networkState,
exceptionResolver = exceptionResolver,
lifecycleOwner = owner,
) {
override val ssiv = binding.ssiv
private var scrollToRestore = 0 private var scrollToRestore = 0
private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar) private val goneOnInvisibleListener = GoneOnInvisibleListener(bindingInfo.progressBar)
init { override fun onConfigChanged(settings: ReaderSettings) {
binding.ssiv.bindToLifecycle(owner) super.onConfigChanged(settings)
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()
if (settings.applyBitmapConfig(binding.ssiv)) { if (settings.applyBitmapConfig(binding.ssiv)) {
delegate.reload() reloadImage()
} }
binding.ssiv.applyDownSampling(isResumed()) binding.ssiv.applyDownSampling(isResumed())
} }
override fun onBind(data: ReaderPage) {
delegate.onBind(data.toMangaPage())
}
override fun onRecycled() { override fun onRecycled() {
super.onRecycled() super.onRecycled()
binding.ssiv.recycle() binding.ssiv.recycle()
@@ -75,31 +53,7 @@ class WebtoonHolder(
goneOnInvisibleListener.detach() goneOnInvisibleListener.detach()
} }
override fun onLoadingStarted() { override fun onReady() {
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) {
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
with(binding.ssiv) { with(binding.ssiv) {
scrollTo( 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 getScrollY() = binding.ssiv.getScroll()
fun restoreScroll(scroll: Int) { fun restoreScroll(scroll: Int) {

View File

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

View File

@@ -31,7 +31,7 @@
<string name="add">Add</string> <string name="add">Add</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="share">Share</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="share_s">Share %s</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="search_manga">Search manga</string> <string name="search_manga">Search manga</string>