Improve trickplay image preview speed (#467)

Moves the clipping of trickplay images from the image loading layer to
the composition/graphics layer.

Effectively, this PR loads and renders the entire trickplay image which
is then moved around to the right position and finally is clipped so
only a single tile is shown. Since moving the image around on screen
plsu clipping is 1) very fast and 2) done during recompositions, this
means it is _much_ faster at rendering each trickplay tile image and
Coil only has to load the image once.

Fixes #432
This commit is contained in:
damontecres 2025-12-14 19:04:01 -05:00 committed by GitHub
parent ff43f8b1b5
commit 7ab39eba57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 50 deletions

View file

@ -53,6 +53,7 @@ fun ItemDetailsDialog(
val audioLabel = stringResource(R.string.audio) val audioLabel = stringResource(R.string.audio)
val subtitleLabel = stringResource(R.string.subtitle) val subtitleLabel = stringResource(R.string.subtitle)
val bitrateLabel = stringResource(R.string.bitrate) val bitrateLabel = stringResource(R.string.bitrate)
val unknown = stringResource(R.string.unknown)
ScrollableDialog( ScrollableDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -100,6 +101,7 @@ fun ItemDetailsDialog(
source.container?.let { add(containerLabel to it) } source.container?.let { add(containerLabel to it) }
if (showFilePath) { if (showFilePath) {
source.path?.let { add(pathLabel to it) } source.path?.let { add(pathLabel to it) }
add("ID" to (source.id ?: unknown))
} }
source.size?.let { source.size?.let {
add(fileSizeLabel to formatBytes(it)) add(fileSizeLabel to formatBytes(it))

View file

@ -398,17 +398,13 @@ fun PlaybackOverlay(
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index = val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val imageUrl = trickplayUrlFor(index) val imageUrl = remember(index) { trickplayUrlFor(index) }
if (imageUrl != null) { if (imageUrl != null) {
SeekPreviewImage( SeekPreviewImage(
modifier = modifier = Modifier,
Modifier,
previewImageUrl = imageUrl, previewImageUrl = imageUrl,
duration = playerControls.duration,
seekProgressMs = seekProgressMs, seekProgressMs = seekProgressMs,
videoWidth = trickplayInfo.width,
videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo, trickPlayInfo = trickplayInfo,
) )
} }

View file

@ -411,7 +411,11 @@ class PlaybackViewModel
trickPlayInfo?.let { trickplayInfo -> trickPlayInfo?.let { trickplayInfo ->
mediaSource.runTimeTicks?.ticks?.let { duration -> mediaSource.runTimeTicks?.ticks?.let { duration ->
viewModelScope.launchIO { viewModelScope.launchIO {
prefetchTrickplay(duration, trickplayInfo) prefetchTrickplay(
duration,
trickplayInfo,
mediaSource.id?.toUUIDOrNull(),
)
} }
} }
} }
@ -737,16 +741,18 @@ class PlaybackViewModel
private suspend fun prefetchTrickplay( private suspend fun prefetchTrickplay(
duration: Duration, duration: Duration,
trickplayInfo: TrickplayInfo, trickplayInfo: TrickplayInfo,
mediaSourceId: UUID?,
) { ) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val totalCount = val totalCount =
(duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1 (duration.inWholeMilliseconds / trickplayInfo.interval).toInt() / tilesPerImage + 1
(0..<totalCount).forEach { (0..<totalCount).forEach {
val url = getTrickplayUrl(it, trickplayInfo) val url = getTrickplayUrl(it, trickplayInfo, mediaSourceId)
context.imageLoader.enqueue( context.imageLoader.enqueue(
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(url) .data(url)
.size(coil3.size.Size.ORIGINAL)
.build(), .build(),
) )
} }
@ -755,10 +761,10 @@ class PlaybackViewModel
fun getTrickplayUrl( fun getTrickplayUrl(
index: Int, index: Int,
trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo, trickPlayInfo: TrickplayInfo? = currentMediaInfo.value?.trickPlayInfo,
mediaSourceId: UUID? = currentItemPlayback.value?.sourceId,
): String? = ): String? =
trickPlayInfo?.let { trickPlayInfo?.let {
val itemId = item.id val itemId = item.id
val mediaSourceId = currentItemPlayback.value?.sourceId
return api.trickplayApi.getTrickplayTileImageUrl( return api.trickplayApi.getTrickplayTileImageUrl(
itemId, itemId,
trickPlayInfo.width, trickPlayInfo.width,

View file

@ -1,23 +1,29 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.rememberAsyncImagePainter
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo import org.jellyfin.sdk.model.api.TrickplayInfo
@ -69,53 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
@Composable @Composable
fun SeekPreviewImage( fun SeekPreviewImage(
previewImageUrl: String, previewImageUrl: String,
duration: Long,
seekProgressMs: Long, seekProgressMs: Long,
videoWidth: Int?,
videoHeight: Int?,
trickPlayInfo: TrickplayInfo, trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
placeHolder: Painter? = null,
) { ) {
val context = LocalContext.current val context = LocalContext.current
if (previewImageUrl.isNotNullOrBlank() && if (previewImageUrl.isNotNullOrBlank()) {
videoWidth != null &&
videoHeight != null
) {
val height = 160.dp val height = 160.dp
val width = height * (videoWidth.toFloat() / videoHeight) val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
val heightPx = with(LocalDensity.current) { height.toPx().toInt() } val scale = LocalDensity.current.density
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile val model =
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth remember(previewImageUrl) {
val imageIndex = index % numberOfTitlesPerImage
AsyncImage(
modifier =
modifier
.width(width)
.height(height)
.background(Color.Black)
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
model =
ImageRequest ImageRequest
.Builder(context) .Builder(context)
.data(previewImageUrl) .data(previewImageUrl)
.transformations( .size(coil3.size.Size.ORIGINAL)
CoilTrickplayTransformation( .build()
widthPx, }
heightPx, val painter =
trickPlayInfo.tileHeight, rememberAsyncImagePainter(
trickPlayInfo.tileWidth, model = model,
imageIndex,
index,
),
).build(),
contentScale = ContentScale.None, contentScale = ContentScale.None,
contentDescription = null, )
placeholder = placeHolder, val index =
(seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Index of tile across images
val numberOfTilesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
val tileIndex =
index % numberOfTilesPerImage // Index of tile within the current image
val x = (tileIndex % trickPlayInfo.tileWidth) // x position within tile grid
val y = (tileIndex / trickPlayInfo.tileHeight) // y position
Box(
modifier =
modifier
.border(1.5.dp, color = MaterialTheme.colorScheme.border)
.background(Color.Black)
.height(height)
.width(width),
) {
Canvas(
modifier =
Modifier
.height(height)
.width(width)
.clip(RectangleShape),
) {
with(painter) {
// Scale and translate to the right position in the tile grid
scale(scale, scale, pivot = Offset.Zero) {
translate(
left = -x.toFloat() * trickPlayInfo.width,
top = -y.toFloat() * trickPlayInfo.height,
) {
draw(
size =
Size(
trickPlayInfo.width * trickPlayInfo.tileWidth.toFloat(),
trickPlayInfo.height * trickPlayInfo.tileHeight.toFloat(),
),
) )
} }
} }
}
}
}
}
}