mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
652becd685
commit
0d8800863b
4 changed files with 81 additions and 13 deletions
|
|
@ -18,3 +18,8 @@ repos:
|
||||||
entry: val DEBUG = true
|
entry: val DEBUG = true
|
||||||
language: pygrep
|
language: pygrep
|
||||||
files: '.+\.kt'
|
files: '.+\.kt'
|
||||||
|
- id: check-debug-logging-enabled
|
||||||
|
name: Check debug enabled
|
||||||
|
entry: debugLogging = true
|
||||||
|
language: pygrep
|
||||||
|
files: '.+\.kt'
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,12 @@ import coil3.compose.setSingletonImageLoaderFactory
|
||||||
import coil3.disk.DiskCache
|
import coil3.disk.DiskCache
|
||||||
import coil3.disk.directory
|
import coil3.disk.directory
|
||||||
import coil3.memory.MemoryCache
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.CacheStrategy
|
||||||
|
import coil3.network.NetworkRequest
|
||||||
|
import coil3.network.NetworkResponse
|
||||||
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
import coil3.network.cachecontrol.CacheControlCacheStrategy
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.Options
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -67,10 +71,39 @@ fun CoilConfig(
|
||||||
.components {
|
.components {
|
||||||
add(
|
add(
|
||||||
OkHttpNetworkFetcherFactory(
|
OkHttpNetworkFetcherFactory(
|
||||||
cacheStrategy = { CacheControlCacheStrategy() },
|
cacheStrategy = { WholphinCacheStrategy(CacheControlCacheStrategy()) },
|
||||||
callFactory = { client },
|
callFactory = { client },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}.build()
|
}.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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import androidx.media3.exoplayer.DecoderCounters
|
||||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
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.ItemPlaybackDao
|
||||||
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
import com.github.damontecres.wholphin.data.ItemPlaybackRepository
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
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.PlaybackInfoDto
|
||||||
import org.jellyfin.sdk.model.api.PlaystateCommand
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
import org.jellyfin.sdk.model.api.PlaystateMessage
|
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.inWholeTicks
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
|
@ -99,6 +102,7 @@ import timber.log.Timber
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -404,6 +408,13 @@ class PlaybackViewModel
|
||||||
?.get(mediaSource.id)
|
?.get(mediaSource.id)
|
||||||
?.values
|
?.values
|
||||||
?.firstOrNull()
|
?.firstOrNull()
|
||||||
|
trickPlayInfo?.let { trickplayInfo ->
|
||||||
|
mediaSource.runTimeTicks?.ticks?.let { duration ->
|
||||||
|
viewModelScope.launchIO {
|
||||||
|
prefetchTrickplay(duration, trickplayInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val chapters = Chapter.fromDto(base, api)
|
val chapters = Chapter.fromDto(base, api)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|
@ -723,18 +734,39 @@ class PlaybackViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTrickplayUrl(index: Int): String? {
|
private suspend fun prefetchTrickplay(
|
||||||
val itemId = item.id
|
duration: Duration,
|
||||||
val mediaSourceId = currentItemPlayback.value?.sourceId
|
trickplayInfo: TrickplayInfo,
|
||||||
val trickPlayInfo = currentMediaInfo.value?.trickPlayInfo ?: return null
|
) {
|
||||||
return api.trickplayApi.getTrickplayTileImageUrl(
|
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
|
||||||
itemId,
|
val totalCount =
|
||||||
trickPlayInfo.width,
|
(duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1
|
||||||
index,
|
(0..<totalCount).forEach {
|
||||||
mediaSourceId,
|
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) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
if (playbackState == Player.STATE_ENDED) {
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.request.CachePolicy
|
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.transformations
|
import coil3.request.transformations
|
||||||
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
|
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
|
||||||
|
|
@ -104,7 +103,6 @@ fun SeekPreviewImage(
|
||||||
ImageRequest
|
ImageRequest
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.data(previewImageUrl)
|
.data(previewImageUrl)
|
||||||
.memoryCachePolicy(CachePolicy.ENABLED)
|
|
||||||
.transformations(
|
.transformations(
|
||||||
CoilTrickplayTransformation(
|
CoilTrickplayTransformation(
|
||||||
widthPx,
|
widthPx,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue