From 0d8800863b940e133481245036af37901f374e32 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Fri, 12 Dec 2025 22:04:52 -0500 Subject: [PATCH] Prefetch trickplay images (#445) Prefetch trickplay images and cache them so that quick scrolling doesn't require network calls and images will appear significantly faster Fixes #432 --- .pre-commit-config.yaml | 5 ++ .../damontecres/wholphin/ui/CoilConfig.kt | 35 ++++++++++++- .../wholphin/ui/playback/PlaybackViewModel.kt | 52 +++++++++++++++---- .../wholphin/ui/playback/SeekPreviewImage.kt | 2 - 4 files changed, 81 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f8ec6d53..75d48ceb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,8 @@ repos: entry: val DEBUG = true language: pygrep files: '.+\.kt' + - id: check-debug-logging-enabled + name: Check debug enabled + entry: debugLogging = true + language: pygrep + files: '.+\.kt' diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt index 3efff666..899498fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/CoilConfig.kt @@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache +import coil3.network.CacheStrategy +import coil3.network.NetworkRequest +import coil3.network.NetworkResponse import coil3.network.cachecontrol.CacheControlCacheStrategy import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import coil3.request.Options import coil3.request.crossfade import coil3.util.DebugLogger import okhttp3.OkHttpClient @@ -67,10 +71,39 @@ fun CoilConfig( .components { add( OkHttpNetworkFetcherFactory( - cacheStrategy = { CacheControlCacheStrategy() }, + cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) }, callFactory = { client }, ), ) }.build() } } + +/** + * This [CacheStrategy] always prefers the cached response for Trickplay images, + * otherwise the decision is delegated to the provided [CacheStrategy] + * + * The expectation is that Trickplay images will be prefetched so the cache will always be warm + */ +@OptIn(ExperimentalCoilApi::class) +private class WholphinCacheStrategy( + private val delegate: CacheStrategy, +) : CacheStrategy { + override suspend fun read( + cacheResponse: NetworkResponse, + networkRequest: NetworkRequest, + options: Options, + ): CacheStrategy.ReadResult = + if (networkRequest.url.contains("/Trickplay/")) { + CacheStrategy.ReadResult(cacheResponse) + } else { + delegate.read(cacheResponse, networkRequest, options) + } + + override suspend fun write( + cacheResponse: NetworkResponse?, + networkRequest: NetworkRequest, + networkResponse: NetworkResponse, + options: Options, + ): CacheStrategy.WriteResult = delegate.write(cacheResponse, networkRequest, networkResponse, options) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 5f8b656c..524bcf03 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import coil3.imageLoader +import coil3.request.ImageRequest import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -92,6 +94,7 @@ import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaystateCommand import org.jellyfin.sdk.model.api.PlaystateMessage +import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.extensions.inWholeTicks import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -99,6 +102,7 @@ import timber.log.Timber import java.util.Date import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -404,6 +408,13 @@ class PlaybackViewModel ?.get(mediaSource.id) ?.values ?.firstOrNull() + trickPlayInfo?.let { trickplayInfo -> + mediaSource.runTimeTicks?.ticks?.let { duration -> + viewModelScope.launchIO { + prefetchTrickplay(duration, trickplayInfo) + } + } + } val chapters = Chapter.fromDto(base, api) withContext(Dispatchers.Main) { @@ -723,18 +734,39 @@ class PlaybackViewModel ) } - fun getTrickplayUrl(index: Int): String? { - val itemId = item.id - val mediaSourceId = currentItemPlayback.value?.sourceId - val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null - return api.trickplayApi.getTrickplayTileImageUrl( - itemId, - trickPlayInfo.width, - index, - mediaSourceId, - ) + private suspend fun prefetchTrickplay( + duration: Duration, + trickplayInfo: TrickplayInfo, + ) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val totalCount = + (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1 + (0..