Support for trickplay images

This commit is contained in:
Damontecres 2025-10-01 11:50:35 -04:00
parent 70d579eea4
commit 9c95685385
No known key found for this signature in database
7 changed files with 316 additions and 59 deletions

View file

@ -0,0 +1,33 @@
package com.github.damontecres.dolphin.ui
import android.graphics.Bitmap
import androidx.core.graphics.scale
import coil3.size.Size
import coil3.size.pxOrElse
import coil3.transform.Transformation
class CoilTrickplayTransformation(
val targetWidth: Int,
val targetHeight: Int,
val numRows: Int,
val numColumns: Int,
val imageIndex: Int,
val index: Int,
) : Transformation() {
private val x: Int = imageIndex % numColumns
private val y: Int = imageIndex / numRows
override val cacheKey: String
get() = "CoilTrickplayTransformation_$index,$x,$y"
override suspend fun transform(
input: Bitmap,
size: Size,
): Bitmap {
val width = input.width / numColumns
val height = input.height / numRows
return Bitmap
.createBitmap(input, x * width, y * height, width, height)
.scale(size.width.pxOrElse { targetWidth }, size.height.pxOrElse { targetHeight })
}
}

View file

@ -26,6 +26,7 @@ val DefaultItemFields =
ItemFields.SEASON_USER_DATA, ItemFields.SEASON_USER_DATA,
ItemFields.CHILD_COUNT, ItemFields.CHILD_COUNT,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.TRICKPLAY,
) )
@Preview( @Preview(

View file

@ -106,6 +106,7 @@ class SeriesViewModel
ItemFields.MEDIA_STREAMS, ItemFields.MEDIA_STREAMS,
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING, ItemFields.CUSTOM_RATING,
ItemFields.TRICKPLAY,
), ),
) )
val pager = ItemPager(api, request, viewModelScope) val pager = ItemPager(api, request, viewModelScope)

View file

@ -73,6 +73,7 @@ fun PlaybackContent(
val duration by viewModel.duration.observeAsState(null) val duration by viewModel.duration.observeAsState(null)
val audioStreams by viewModel.audioStreams.observeAsState(listOf()) val audioStreams by viewModel.audioStreams.observeAsState(listOf())
val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf()) val subtitleStreams by viewModel.subtitleStreams.observeAsState(listOf())
val trickplay by viewModel.trickplay.observeAsState(null)
if (stream == null) { if (stream == null) {
// TODO loading // TODO loading
@ -228,6 +229,8 @@ fun PlaybackContent(
subtitleIndex = null, subtitleIndex = null,
audioIndex = null, audioIndex = null,
audioStreams = audioStreams, audioStreams = audioStreams,
trickplayInfo = trickplay,
trickplayUrlFor = viewModel::getTrickplayUrl,
) )
} }
} }

View file

@ -1,21 +1,26 @@
package com.github.damontecres.dolphin.ui.playback package com.github.damontecres.dolphin.ui.playback
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.tv.material3.Text import androidx.tv.material3.Text
import org.jellyfin.sdk.model.api.TrickplayInfo
import timber.log.Timber
@Composable @Composable
fun PlaybackOverlay( fun PlaybackOverlay(
@ -38,12 +43,14 @@ fun PlaybackOverlay(
audioStreams: List<AudioStream>, audioStreams: List<AudioStream>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
trickplayInfo: TrickplayInfo? = null,
trickplayUrlFor: (Int) -> String? = { null },
seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) { ) {
// Will be used for preview/trick play images // Will be used for preview/trick play images
var seekProgress by remember { mutableFloatStateOf(-1f) } var seekProgressPercent by remember { mutableFloatStateOf(-1f) }
val seekProgressMs = seekProgressPercent * playerControls.duration
val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState()
var seekBarDragging by remember { mutableStateOf(false) }
Box( Box(
modifier = modifier, modifier = modifier,
@ -60,6 +67,45 @@ fun PlaybackOverlay(
text = it, text = it,
) )
} }
if (seekBarInteractionSource.collectIsFocusedAsState().value) {
LaunchedEffect(Unit) {
seekProgressPercent =
(playerControls.currentPosition.toFloat() / playerControls.duration)
}
}
if (trickplayInfo != null) {
AnimatedVisibility(seekProgressPercent >= 0 && seekBarFocused) {
val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight
val index =
(seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage
val yOffsetDp = 180.dp + 160.dp
val heightPx = with(LocalDensity.current) { yOffsetDp.toPx().toInt() }
val imageUrl = trickplayUrlFor(index)
Timber.v("Trickplay imageUrl: $imageUrl")
if (imageUrl != null) {
SeekPreviewImage(
modifier =
Modifier
// .align(Alignment.TopStart)
.offsetByPercent(
xPercentage = seekProgressPercent.coerceIn(0f, 1f),
// yOffset = heightPx,
// yPercentage = 1 - controlHeight,
),
previewImageUrl = imageUrl,
duration = playerControls.duration,
seekProgress = seekProgressPercent,
videoWidth = trickplayInfo.width,
videoHeight = trickplayInfo.height,
trickPlayInfo = trickplayInfo,
)
}
}
}
PlaybackControls( PlaybackControls(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
subtitleStreams = subtitleStreams, subtitleStreams = subtitleStreams,
@ -68,7 +114,7 @@ fun PlaybackOverlay(
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
showDebugInfo = showDebugInfo, showDebugInfo = showDebugInfo,
onSeekProgress = { onSeekProgress = {
seekProgress = it seekProgressPercent = it
onSeekBarChange(it) onSeekBarChange(it)
}, },
showPlay = showPlay, showPlay = showPlay,

View file

@ -19,13 +19,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.client.extensions.videosApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlaybackInfoDto import org.jellyfin.sdk.model.api.PlaybackInfoDto
import org.jellyfin.sdk.model.api.TrickplayInfo
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -74,8 +78,10 @@ class PlaybackViewModel
val audioStreams = MutableLiveData<List<AudioStream>>(listOf()) val audioStreams = MutableLiveData<List<AudioStream>>(listOf())
val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf()) val subtitleStreams = MutableLiveData<List<SubtitleStream>>(listOf())
val currentPlayback = MutableLiveData<CurrentPlayback?>(null) val currentPlayback = MutableLiveData<CurrentPlayback?>(null)
val trickplay = MutableLiveData<TrickplayInfo?>(null)
private lateinit var deviceProfile: DeviceProfile private lateinit var deviceProfile: DeviceProfile
private lateinit var dto: BaseItemDto
init { init {
addCloseable { player.release() } addCloseable { player.release() }
@ -91,6 +97,7 @@ class PlaybackViewModel
val item = destination.item val item = destination.item
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
dto = base
val title = base.name val title = base.name
val subtitle = val subtitle =
@ -170,7 +177,7 @@ class PlaybackViewModel
} }
} }
fun changeStreams( private suspend fun changeStreams(
itemId: UUID, itemId: UUID,
audioIndex: Int?, audioIndex: Int?,
subtitleIndex: Int?, subtitleIndex: Int?,
@ -185,72 +192,111 @@ class PlaybackViewModel
Timber.i("No change in playback for changeStreams") Timber.i("No change in playback for changeStreams")
return return
} }
viewModelScope.launch(Dispatchers.IO) { val response =
val response = api.mediaInfoApi
api.mediaInfoApi .getPostedPlaybackInfo(
.getPostedPlaybackInfo( itemId,
itemId, // TODO device profile, etc
// TODO device profile, etc PlaybackInfoDto(
PlaybackInfoDto( startTimeTicks = null,
startTimeTicks = null, deviceProfile = deviceProfile,
deviceProfile = deviceProfile, enableDirectPlay = true,
enableDirectPlay = true, enableDirectStream = true,
enableDirectStream = true, maxAudioChannels = null,
maxAudioChannels = null, audioStreamIndex = audioIndex,
audioStreamIndex = audioIndex, subtitleStreamIndex = subtitleIndex,
subtitleStreamIndex = subtitleIndex, allowVideoStreamCopy = true,
allowVideoStreamCopy = true, allowAudioStreamCopy = true,
allowAudioStreamCopy = true, autoOpenLiveStream = true,
autoOpenLiveStream = true, mediaSourceId = null,
mediaSourceId = null, ),
), ).content
).content val source = response.mediaSources.firstOrNull()
val source = response.mediaSources.firstOrNull() source?.let { source ->
source?.let { source -> val mediaUrl =
val mediaUrl = if (source.supportsDirectPlay) {
if (source.supportsDirectPlay) { api.videosApi.getVideoStreamUrl(
api.videosApi.getVideoStreamUrl( itemId = itemId,
itemId = itemId, mediaSourceId = source.id,
mediaSourceId = source.id, static = true,
static = true, tag = source.eTag,
tag = source.eTag,
)
} else if (source.supportsDirectStream) {
api.createUrl(source.transcodingUrl!!)
} else {
api.createUrl(source.transcodingUrl!!)
}
val transcodeType =
when {
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM
source.supportsTranscoding -> TranscodeType.TRANSCODE
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
withContext(Dispatchers.Main) {
duration.value = source.runTimeTicks?.ticks
stream.value = decision
currentPlayback.value = CurrentPlayback(itemId, audioIndex, subtitleIndex)
player.setMediaItem(
decision.mediaItem,
positionMs,
) )
} else if (source.supportsDirectStream) {
api.createUrl(source.transcodingUrl!!)
} else {
api.createUrl(source.transcodingUrl!!)
} }
val transcodeType =
when {
source.supportsDirectPlay -> TranscodeType.DIRECT_PLAY
source.supportsDirectStream -> TranscodeType.DIRECT_STREAM
source.supportsTranscoding -> TranscodeType.TRANSCODE
else -> throw Exception("No supported playback method")
}
val decision = StreamDecision(itemId, transcodeType, mediaUrl)
Timber.v("Playback decision: $decision")
withContext(Dispatchers.Main) {
duration.value = source.runTimeTicks?.ticks
stream.value = decision
currentPlayback.value =
CurrentPlayback(
itemId,
audioIndex,
subtitleIndex,
source.id?.toUUIDOrNull(),
)
player.setMediaItem(
decision.mediaItem,
positionMs,
)
}
val trickPlayInfo =
dto.trickplay
?.get(source.id)
?.values
?.firstOrNull()
Timber.v("Trickplay info: $trickPlayInfo")
withContext(Dispatchers.Main) {
trickplay.value = trickPlayInfo
} }
} }
} }
fun changeAudioStream(index: Int) { fun changeAudioStream(index: Int) {
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
changeStreams(itemId, index, currentPlayback.value?.subtitleIndex, player.currentPosition) viewModelScope.launch {
changeStreams(
itemId,
index,
currentPlayback.value?.subtitleIndex,
player.currentPosition,
)
}
} }
fun changeSubtitleStream(index: Int?) { fun changeSubtitleStream(index: Int?) {
val itemId = currentPlayback.value?.itemId ?: return val itemId = currentPlayback.value?.itemId ?: return
changeStreams(itemId, currentPlayback.value?.audioIndex, index, player.currentPosition) viewModelScope.launch {
changeStreams(
itemId,
currentPlayback.value?.audioIndex,
index,
player.currentPosition,
)
}
}
fun getTrickplayUrl(index: Int): String? {
val itemId = dto.id
val mediaSourceId = currentPlayback.value?.mediaSourceId
val trickPlayInfo = trickplay.value ?: return null
return api.trickplayApi.getTrickplayTileImageUrl(
itemId,
trickPlayInfo.width,
index,
mediaSourceId,
)
} }
} }
@ -258,4 +304,5 @@ data class CurrentPlayback(
val itemId: UUID, val itemId: UUID,
val audioIndex: Int?, val audioIndex: Int?,
val subtitleIndex: Int?, val subtitleIndex: Int?,
val mediaSourceId: UUID?,
) )

View file

@ -0,0 +1,126 @@
package com.github.damontecres.dolphin.ui.playback
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
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 androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.CachePolicy
import coil3.request.ImageRequest
import coil3.request.transformations
import com.github.damontecres.dolphin.ui.CoilTrickplayTransformation
import com.github.damontecres.dolphin.ui.isNotNullOrBlank
import org.jellyfin.sdk.model.api.TrickplayInfo
import kotlin.time.Duration.Companion.seconds
fun Modifier.offsetByPercent(
xPercentage: Float,
yOffset: Int,
) = this.then(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x =
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
.coerceIn(0, constraints.maxWidth - placeable.width),
y = constraints.maxHeight - yOffset, // (constraints.maxHeight * yPercentage).toInt() - (placeable.height / 1.33f).toInt(),
)
}
},
)
fun Modifier.offsetByPercent(xPercentage: Float) =
this.then(
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x =
((constraints.maxWidth * xPercentage).toInt() - placeable.width / 2)
.coerceIn(0, constraints.maxWidth - placeable.width),
y = 0,
)
}
},
)
@Composable
fun SeekPreviewImage(
previewImageUrl: String,
duration: Long,
seekProgress: Float,
videoWidth: Int?,
videoHeight: Int?,
trickPlayInfo: TrickplayInfo,
modifier: Modifier = Modifier,
placeHolder: Painter? = null,
) {
val context = LocalContext.current
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (previewImageUrl.isNotNullOrBlank() &&
videoWidth != null &&
videoHeight != null
) {
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 index = (duration * seekProgress / 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 =
ImageRequest
.Builder(context)
.data(previewImageUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.transformations(
CoilTrickplayTransformation(
widthPx,
heightPx,
trickPlayInfo.tileHeight,
trickPlayInfo.tileWidth,
imageIndex,
index,
),
).build(),
contentScale = ContentScale.None,
contentDescription = null,
placeholder = placeHolder,
)
}
Text(
text = (seekProgress * duration / 1000).toLong().seconds.toString(),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.labelLarge,
)
}
}