Use page preview in reader while loading

This commit is contained in:
Koitharu
2025-03-22 16:18:35 +02:00
parent 1a5c3c1f6f
commit 24cf2a2725
10 changed files with 110 additions and 30 deletions

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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()

View File

@@ -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() {
state = State.SHOWING if (state >= State.LOADED) {
error = null state = State.SHOWING
callback.onImageShowing(readerSettings) 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() {
state = State.SHOWN if (state >= State.LOADED) {
error = null state = State.SHOWN
callback.onImageShown() error = null
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() uri = file
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()

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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(

View File

@@ -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"

View File

@@ -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"