Use page preview in reader while loading

master
Koitharu 1 year ago
parent 1a5c3c1f6f
commit 24cf2a2725
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class RetainedLifecycleCoroutineScope( class RetainedLifecycleCoroutineScope(
@ -14,7 +15,9 @@ class RetainedLifecycleCoroutineScope(
override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate
init { init {
lifecycle.addOnClearedListener(this) launch(Dispatchers.Main.immediate) {
lifecycle.addOnClearedListener(this@RetainedLifecycleCoroutineScope)
}
} }
override fun onCleared() { override fun onCleared() {

@ -3,10 +3,12 @@ package org.koitharu.kotatsu.details.ui.pager.pages
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader import coil3.ImageLoader
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.transformations
import coil3.size.Scale import coil3.size.Scale
import coil3.size.Size import coil3.size.Size
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.decodeRegion
@ -43,6 +45,7 @@ fun pageThumbnailAD(
size(thumbSize) size(thumbSize)
scale(Scale.FILL) scale(Scale.FILL)
allowRgb565(true) allowRgb565(true)
transformations(TrimTransformation())
decodeRegion(0) decodeRegion(0)
mangaSourceExtra(item.page.source) mangaSourceExtra(item.page.source)
enqueueWith(coil) enqueueWith(coil)

@ -8,9 +8,15 @@ import androidx.collection.LongSparseArray
import androidx.collection.set import androidx.collection.set
import androidx.core.net.toFile import androidx.core.net.toFile
import androidx.core.net.toUri import androidx.core.net.toUri
import coil3.BitmapImage
import coil3.Image
import coil3.ImageLoader
import coil3.memory.MemoryCache
import coil3.request.ImageRequest
import coil3.request.transformations
import coil3.toBitmap
import com.davemorrissey.labs.subscaleview.ImageSource import com.davemorrissey.labs.subscaleview.ImageSource
import dagger.hilt.android.ActivityRetainedLifecycle import dagger.hilt.android.ActivityRetainedLifecycle
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ActivityRetainedScoped import dagger.hilt.android.scopes.ActivityRetainedScoped
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@ -24,6 +30,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okio.use import okio.use
@ -36,9 +43,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.CachingMangaRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin
import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.compressToPNG
@ -49,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri
import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isNotEmpty
import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode
import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.isZipUri
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.ramAvailable import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.toMimeType
@ -76,13 +85,14 @@ class PageLoader @Inject constructor(
lifecycle: ActivityRetainedLifecycle, lifecycle: ActivityRetainedLifecycle,
@MangaHttpClient private val okHttp: OkHttpClient, @MangaHttpClient private val okHttp: OkHttpClient,
private val cache: PagesCache, private val cache: PagesCache,
private val coil: ImageLoader,
private val settings: AppSettings, private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val imageProxyInterceptor: ImageProxyInterceptor, private val imageProxyInterceptor: ImageProxyInterceptor,
private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher,
) { ) {
val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.Default
private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>() private val tasks = LongSparseArray<ProgressDeferred<Uri, Float>>()
private val semaphore = Semaphore(3) private val semaphore = Semaphore(3)
@ -121,6 +131,41 @@ class PageLoader @Inject constructor(
} }
} }
suspend fun loadPreview(page: MangaPage): ImageSource? {
val preview = page.preview
if (preview.isNullOrEmpty()) {
return null
}
val request = ImageRequest.Builder(context)
.data(preview)
.mangaSourceExtra(page.source)
.transformations(TrimTransformation())
.build()
return coil.execute(request).image?.toImageSource()
}
fun peekPreviewSource(preview: String?): ImageSource? {
if (preview.isNullOrEmpty()) {
return null
}
coil.memoryCache?.let { cache ->
val key = MemoryCache.Key(preview)
cache[key]?.image?.let {
return if (it is BitmapImage) {
ImageSource.cachedBitmap(it.toBitmap())
} else {
ImageSource.bitmap(it.toBitmap())
}
}
}
coil.diskCache?.let { cache ->
cache.openSnapshot(preview)?.use { snapshot ->
return ImageSource.file(snapshot.data.toFile())
}
}
return null
}
fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> { fun loadPageAsync(page: MangaPage, force: Boolean): ProgressDeferred<Uri, Float> {
var task = tasks[page.id]?.takeIf { it.isValid() } var task = tasks[page.id]?.takeIf { it.isValid() }
if (force) { if (force) {
@ -237,7 +282,7 @@ class PageLoader @Inject constructor(
if (!skipCache) { if (!skipCache) {
cache.get(pageUrl)?.let { return it.toUri() } cache.get(pageUrl)?.let { return it.toUri() }
} }
val uri = Uri.parse(pageUrl) val uri = pageUrl.toUri()
return when { return when {
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) { uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
uri uri
@ -264,6 +309,12 @@ class PageLoader @Inject constructor(
return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES) return context.ramAvailable <= FileSize.MEGABYTES.convert(PREFETCH_MIN_RAM_MB, FileSize.BYTES)
} }
private fun Image.toImageSource(): ImageSource = if (this is BitmapImage) {
ImageSource.cachedBitmap(toBitmap())
} else {
ImageSource.bitmap(toBitmap())
}
private fun Deferred<Uri>.isValid(): Boolean { private fun Deferred<Uri>.isValid(): Boolean {
return getCompletionResultOrNull()?.map { uri -> return getCompletionResultOrNull()?.map { uri ->
uri.exists() && uri.isTargetNotEmpty() uri.exists() && uri.isTargetNotEmpty()

@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
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
@ -108,19 +107,29 @@ class PageHolderDelegate(
} }
override fun onReady() { override fun onReady() {
if (state >= State.LOADED) {
state = State.SHOWING state = State.SHOWING
error = null error = null
callback.onImageShowing(readerSettings) 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 = State.SHOWN state = State.SHOWN
error = null error = null
callback.onImageShown() callback.onImageShown()
} }
}
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 val uri = this.uri
error = e error = e
if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) { if (state == State.LOADED && e is IOException && uri != null && uri.toFileOrNull()?.exists() != false) {
@ -133,7 +142,7 @@ class PageHolderDelegate(
override fun onChanged(value: ReaderSettings) { override fun onChanged(value: ReaderSettings) {
if (state == State.SHOWN) { if (state == State.SHOWN) {
callback.onImageShowing(readerSettings) callback.onImageShowing(readerSettings, isPreview = false)
} }
callback.onConfigChanged() callback.onConfigChanged()
} }
@ -172,21 +181,25 @@ class PageHolderDelegate(
} }
} }
private suspend fun doLoad(data: MangaPage, force: Boolean) { private suspend fun doLoad(data: MangaPage, force: Boolean) = coroutineScope {
state = State.LOADING state = State.LOADING
error = null error = null
callback.onLoadingStarted() callback.onLoadingStarted()
yield() launch {
val preview = loader.loadPreview(data) ?: return@launch
if (state == State.LOADING) {
state = State.LOADING_WITH_PREVIEW
callback.onPreviewReady(preview)
}
}
try { try {
val task = withContext(Dispatchers.Default) { val task = withContext(Dispatchers.Default) {
loader.loadPageAsync(data, force) loader.loadPageAsync(data, force)
} }
uri = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow()) val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await() val file = task.await()
progressObserver.cancelAndJoin() progressObserver.cancelAndJoin()
file uri = file
}
state = State.LOADED state = State.LOADED
cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) { cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) {
loader.getTrimmedBounds(checkNotNull(uri)) loader.getTrimmedBounds(checkNotNull(uri))
@ -223,7 +236,7 @@ class PageHolderDelegate(
} }
enum class State { enum class State {
EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR EMPTY, LOADING, LOADING_WITH_PREVIEW, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR
} }
interface Callback { interface Callback {
@ -232,9 +245,11 @@ class PageHolderDelegate(
fun onError(e: Throwable) fun onError(e: Throwable)
fun onPreviewReady(source: ImageSource)
fun onImageReady(source: ImageSource) fun onImageReady(source: ImageSource)
fun onImageShowing(settings: ReaderSettings) fun onImageShowing(settings: ReaderSettings, isPreview: Boolean)
fun onImageShown() fun onImageShown()

@ -35,7 +35,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) { override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * maxOf( maxScale = 2f * maxOf(
width / sWidth.toFloat(), width / sWidth.toFloat(),

@ -27,7 +27,7 @@ class ReversedPageHolder(
.gravity = Gravity.START or Gravity.BOTTOM .gravity = Gravity.START or Gravity.BOTTOM
} }
override fun onImageShowing(settings: ReaderSettings) { override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) {
with(binding.ssiv) { with(binding.ssiv) {
maxScale = 2f * maxOf( maxScale = 2f * maxOf(
width / sWidth.toFloat(), width / sWidth.toFloat(),

@ -89,11 +89,15 @@ open class PageHolder(
} }
} }
override fun onPreviewReady(source: ImageSource) {
binding.ssiv.setImage(source)
}
override fun onImageReady(source: ImageSource) { override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source) binding.ssiv.setImage(source)
} }
override fun onImageShowing(settings: ReaderSettings) { 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(),

@ -89,11 +89,13 @@ class WebtoonHolder(
} }
} }
override fun onPreviewReady(source: ImageSource) = Unit
override fun onImageReady(source: ImageSource) { override fun onImageReady(source: ImageSource) {
binding.ssiv.setImage(source) binding.ssiv.setImage(source)
} }
override fun onImageShowing(settings: ReaderSettings) { 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(

@ -11,7 +11,9 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center" android:layout_gravity="center"
android:indeterminate="true" android:indeterminate="true"
android:max="100" /> android:max="100"
app:hideAnimationBehavior="escape"
app:showAnimationBehavior="outward" />
<LinearLayout <LinearLayout
android:id="@+id/layout_error" android:id="@+id/layout_error"

@ -31,11 +31,11 @@ material = "1.13.0-alpha11"
moshi = "1.15.2" moshi = "1.15.2"
okhttp = "4.12.0" okhttp = "4.12.0"
okio = "3.10.2" okio = "3.10.2"
parsers = "bebc615376" parsers = "5fa7590550"
preference = "1.2.1" preference = "1.2.1"
recyclerview = "1.4.0" recyclerview = "1.4.0"
room = "2.6.1" room = "2.6.1"
ssiv = "ba48c29803" ssiv = "9a67b6a7c9"
swiperefreshlayout = "1.1.0" swiperefreshlayout = "1.1.0"
testRules = "1.6.1" testRules = "1.6.1"
testRunner = "1.6.2" testRunner = "1.6.2"

Loading…
Cancel
Save