Share one cover load between rows that show it at the same time

This commit is contained in:
AntsyLich
2026-10-07 19:59:51 +06:00
parent 730e43fd14
commit 1f85a5a8db
4 changed files with 76 additions and 1 deletions
@@ -31,6 +31,7 @@ import eu.kanade.tachiyomi.crash.CrashActivity
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
import eu.kanade.tachiyomi.data.coil.CoverRequestInterceptor
import eu.kanade.tachiyomi.data.coil.ImageDecoder
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
@@ -214,6 +215,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
// Keyer
add(MangaCoverKeyer(coverCache))
add(MangaKeyer())
// Interceptor
add(CoverRequestInterceptor())
}
memoryCache(
@@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.coil
import coil3.intercept.Interceptor
import coil3.request.ImageResult
import coil3.request.SuccessResult
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.manga.model.MangaCover
/**
* De-duplicates concurrent requests for the same cover at the same size. Lists like updates show an entry's cover on
* every row, and requests that start together would each miss the memory cache and decode the cover again, so the
* waiting ones are served from the memory cache once the first one finishes.
*/
class CoverRequestInterceptor : Interceptor {
private val concurrentRequests = DeDupeConcurrentRequests<ImageResult> { it is SuccessResult }
override suspend fun intercept(chain: Interceptor.Chain): ImageResult {
val data = chain.request.data
if (data !is MangaCover && data !is Manga) return chain.proceed()
return concurrentRequests.apply(data to chain.size) { chain.proceed() }
}
}
@@ -0,0 +1,45 @@
package eu.kanade.tachiyomi.data.coil
import kotlinx.coroutines.CompletableDeferred
/**
* De-duplicates concurrent requests for the same key, like Coil's `DeDupeConcurrentRequestStrategy` but for any
* result, so it works for decoded images as well as fetches.
*
* The first caller runs `block`. If it succeeds, the waiting callers run `block` too, for example to read what the
* first one cached. If it fails or is canceled, one of them runs it instead.
*
* @param isSuccess whether a result counts as a success, for results that report failure without throwing.
*/
class DeDupeConcurrentRequests<T>(
private val isSuccess: (T) -> Boolean = { true },
) {
private val requests = mutableMapOf<Any, CompletableDeferred<Boolean>>()
suspend fun apply(key: Any, block: suspend () -> T): T {
while (true) {
var isLeader = false
val request = synchronized(requests) {
requests.getOrPut(key) {
isLeader = true
CompletableDeferred()
}
}
if (!isLeader) {
if (request.await()) return block()
continue
}
var succeeded = false
try {
return block().also { succeeded = isSuccess(it) }
} finally {
synchronized(requests) {
requests -= key
}
request.complete(succeeded)
}
}
}
}
@@ -74,7 +74,8 @@ class MangaCoverFetcher(
return when (getResourceType(url)) {
Type.File -> fileLoader(File(url.substringAfter("file://")))
Type.URI -> fileUriLoader(url)
Type.URL -> httpLoader()
// Requests for the same cover at different sizes or through Manga and MangaCover would each download it
Type.URL -> concurrentRequests.apply(diskCacheKey) { httpLoader() }
null -> error("Invalid image")
}
}
@@ -346,5 +347,7 @@ class MangaCoverFetcher(
private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build()
private const val HTTP_NOT_MODIFIED = 304
private val concurrentRequests = DeDupeConcurrentRequests<FetchResult>()
}
}