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
This commit is contained in:
damontecres 2025-12-12 22:04:52 -05:00 committed by GitHub
parent 652becd685
commit 0d8800863b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 81 additions and 13 deletions

View file

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

View file

@ -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..<totalCount).forEach {
val url = getTrickplayUrl(it, trickplayInfo)
context.imageLoader.enqueue(
ImageRequest
.Builder(context)
.data(url)
.build(),
)
}
}
fun getTrickplayUrl(
index: Int,
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
): String? =
trickPlayInfo?.let {
val itemId = item.id
val mediaSourceId = currentItemPlayback.value?.sourceId
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
index,
mediaSourceId,
)
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
viewModelScope.launchIO {

View file

@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
@ -104,7 +103,6 @@ fun SeekPreviewImage(
ImageRequest
.Builder(context)
.data(previewImageUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.transformations(
CoilTrickplayTransformation(
widthPx,