diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt index b14e842c7..b7c1312c3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/RetainedLifecycleCoroutineScope.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext class RetainedLifecycleCoroutineScope( @@ -14,7 +15,9 @@ class RetainedLifecycleCoroutineScope( override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Main.immediate init { - lifecycle.addOnClearedListener(this) + launch(Dispatchers.Main.immediate) { + lifecycle.addOnClearedListener(this@RetainedLifecycleCoroutineScope) + } } override fun onCleared() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt index 71d2f26c6..1e907039e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PageThumbnailAD.kt @@ -3,10 +3,12 @@ package org.koitharu.kotatsu.details.ui.pager.pages import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import coil3.request.allowRgb565 +import coil3.request.transformations import coil3.size.Scale import coil3.size.Size import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding 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.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.decodeRegion @@ -43,6 +45,7 @@ fun pageThumbnailAD( size(thumbSize) scale(Scale.FILL) allowRgb565(true) + transformations(TrimTransformation()) decodeRegion(0) mangaSourceExtra(item.page.source) enqueueWith(coil) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index 9dc2b9535..708666d2e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -8,9 +8,15 @@ import androidx.collection.LongSparseArray import androidx.collection.set import androidx.core.net.toFile 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 dagger.hilt.android.ActivityRetainedLifecycle -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.scopes.ActivityRetainedScoped import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Deferred @@ -24,6 +30,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request 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.MangaRepository 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.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.cancelChildrenAndJoin 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.isPowerSaveMode 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.ramAvailable import org.koitharu.kotatsu.core.util.ext.toMimeType @@ -76,13 +85,14 @@ class PageLoader @Inject constructor( lifecycle: ActivityRetainedLifecycle, @MangaHttpClient private val okHttp: OkHttpClient, private val cache: PagesCache, + private val coil: ImageLoader, private val settings: AppSettings, private val mangaRepositoryFactory: MangaRepository.Factory, private val imageProxyInterceptor: ImageProxyInterceptor, private val downloadSlowdownDispatcher: DownloadSlowdownDispatcher, ) { - val loaderScope = RetainedLifecycleCoroutineScope(lifecycle) + InternalErrorHandler() + Dispatchers.Default + val loaderScope = lifecycle.lifecycleScope + InternalErrorHandler() + Dispatchers.Default private val tasks = LongSparseArray>() 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 { var task = tasks[page.id]?.takeIf { it.isValid() } if (force) { @@ -237,7 +282,7 @@ class PageLoader @Inject constructor( if (!skipCache) { cache.get(pageUrl)?.let { return it.toUri() } } - val uri = Uri.parse(pageUrl) + val uri = pageUrl.toUri() return when { uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) { uri @@ -264,6 +309,12 @@ class PageLoader @Inject constructor( 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.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 9a5fb85e4..e5fa4d374 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.plus import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -108,19 +107,29 @@ class PageHolderDelegate( } override fun onReady() { - state = State.SHOWING - error = null - callback.onImageShowing(readerSettings) + 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() { - state = State.SHOWN - error = null - callback.onImageShown() + if (state >= State.LOADED) { + state = State.SHOWN + error = null + callback.onImageShown() + } } 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) { @@ -133,7 +142,7 @@ class PageHolderDelegate( override fun onChanged(value: ReaderSettings) { if (state == State.SHOWN) { - callback.onImageShowing(readerSettings) + callback.onImageShowing(readerSettings, isPreview = false) } 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 error = null callback.onLoadingStarted() - yield() + launch { + val preview = loader.loadPreview(data) ?: return@launch + if (state == State.LOADING) { + state = State.LOADING_WITH_PREVIEW + callback.onPreviewReady(preview) + } + } try { val task = withContext(Dispatchers.Default) { loader.loadPageAsync(data, force) } - uri = coroutineScope { - val progressObserver = observeProgress(this, task.progressAsFlow()) - val file = task.await() - progressObserver.cancelAndJoin() - file - } + val progressObserver = observeProgress(this, task.progressAsFlow()) + val file = task.await() + progressObserver.cancelAndJoin() + uri = file state = State.LOADED cachedBounds = if (readerSettings.isPagesCropEnabled(isWebtoon)) { loader.getTrimmedBounds(checkNotNull(uri)) @@ -223,7 +236,7 @@ class PageHolderDelegate( } enum class State { - EMPTY, LOADING, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR + EMPTY, LOADING, LOADING_WITH_PREVIEW, LOADED, CONVERTING, CONVERTED, SHOWING, SHOWN, ERROR } interface Callback { @@ -232,9 +245,11 @@ class PageHolderDelegate( fun onError(e: Throwable) + fun onPreviewReady(source: ImageSource) + fun onImageReady(source: ImageSource) - fun onImageShowing(settings: ReaderSettings) + fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) fun onImageShown() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageHolder.kt index aac9591bc..280b9b50f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/doublepage/DoublePageHolder.kt @@ -35,7 +35,7 @@ class DoublePageHolder( .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) { maxScale = 2f * maxOf( width / sWidth.toFloat(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt index 2348965a0..90719f3e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/reversed/ReversedPageHolder.kt @@ -27,7 +27,7 @@ class ReversedPageHolder( .gravity = Gravity.START or Gravity.BOTTOM } - override fun onImageShowing(settings: ReaderSettings) { + override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) { with(binding.ssiv) { maxScale = 2f * maxOf( width / sWidth.toFloat(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index b5eefe56e..92dcbb13e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -89,11 +89,15 @@ open class PageHolder( } } + override fun onPreviewReady(source: ImageSource) { + binding.ssiv.setImage(source) + } + override fun onImageReady(source: ImageSource) { binding.ssiv.setImage(source) } - override fun onImageShowing(settings: ReaderSettings) { + override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) { binding.ssiv.maxScale = 2f * maxOf( binding.ssiv.width / binding.ssiv.sWidth.toFloat(), binding.ssiv.height / binding.ssiv.sHeight.toFloat(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 81d65e676..cf0a12625 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -89,11 +89,13 @@ class WebtoonHolder( } } + override fun onPreviewReady(source: ImageSource) = Unit + override fun onImageReady(source: ImageSource) { binding.ssiv.setImage(source) } - override fun onImageShowing(settings: ReaderSettings) { + override fun onImageShowing(settings: ReaderSettings, isPreview: Boolean) { binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() with(binding.ssiv) { scrollTo( diff --git a/app/src/main/res/layout/layout_page_info.xml b/app/src/main/res/layout/layout_page_info.xml index e0c6ebf56..f1d30b81c 100644 --- a/app/src/main/res/layout/layout_page_info.xml +++ b/app/src/main/res/layout/layout_page_info.xml @@ -11,7 +11,9 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:indeterminate="true" - android:max="100" /> + android:max="100" + app:hideAnimationBehavior="escape" + app:showAnimationBehavior="outward" />