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

View file

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

View file

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

View file

@ -1,23 +1,29 @@
package com.github.damontecres.wholphin.ui.playback
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.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.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import coil3.compose.AsyncImage
import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.wholphin.ui.CoilTrickplayTransformation
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo
@ -69,53 +75,70 @@ fun Modifier.offsetByPercent(xPercentage: Float) =
@Composable
fun SeekPreviewImage(
previewImageUrl: String,
duration: Long,
seekProgressMs: Long,
videoWidth: Int?,
videoHeight: Int?,
trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier,
placeHolder: Painter? = null,
) {
val context = LocalContext.current
if (previewImageUrl.isNotNullOrBlank() &&
videoWidth != null &&
videoHeight != null
) {
if (previewImageUrl.isNotNullOrBlank()) {
val height = 160.dp
val width = height * (videoWidth.toFloat() / videoHeight)
val heightPx = with(LocalDensity.current) { height.toPx().toInt() }
val widthPx = with(LocalDensity.current) { width.toPx().toInt() }
val width = height * (trickPlayInfo.width.toFloat() / trickPlayInfo.height)
val scale = LocalDensity.current.density
val index = (seekProgressMs.toDouble() / trickPlayInfo.interval).toInt() // Which tile
val numberOfTitlesPerImage = trickPlayInfo.tileHeight * trickPlayInfo.tileWidth
val imageIndex = index % numberOfTitlesPerImage
AsyncImage(
modifier =
modifier
.width(width)
.height(height)
.background(Color.Black)
.border(1.5.dp, color = MaterialTheme.colorScheme.border),
model =
val model =
remember(previewImageUrl) {
ImageRequest
.Builder(context)
.data(previewImageUrl)
.transformations(
CoilTrickplayTransformation(
widthPx,
heightPx,
trickPlayInfo.tileHeight,
trickPlayInfo.tileWidth,
imageIndex,
index,
),
).build(),
contentScale = ContentScale.None,
contentDescription = null,
placeholder = placeHolder,
)
.size(coil3.size.Size.ORIGINAL)
.build()
}
val painter =
rememberAsyncImagePainter(
model = model,
contentScale = ContentScale.None,
)
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(),
),
)
}
}
}
}
}
}
}