Skip to content

Commit

Permalink
Implement TrickPlay images
Browse files Browse the repository at this point in the history
  • Loading branch information
nielsvanvelzen committed Nov 5, 2024
1 parent e1867b2 commit 77ef06d
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ class UserPreferences(context: Context) : SharedPreferenceStore(
* Preferred behavior for player aspect ratio (zoom mode).
*/
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)

/**
* Enable TrickPlay in legacy player user interface while seeking.
*/
var trickPlayEnabled = booleanPreference("trick_play_enabled", false)
}

init {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,104 @@
package org.jellyfin.androidtv.ui.playback.overlay

import android.content.Context
import androidx.core.graphics.drawable.toBitmap
import androidx.leanback.widget.PlaybackSeekDataProvider
import coil.ImageLoader
import coil.request.Disposable
import coil.request.ImageRequest
import coil.size.Size
import org.jellyfin.androidtv.util.coil.SubsetTransformation
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.trickplayApi
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import kotlin.math.ceil
import kotlin.math.min

class CustomSeekProvider(
private val videoPlayerAdapter: VideoPlayerAdapter,
private val imageLoader: ImageLoader,
private val api: ApiClient,
private val context: Context,
private val trickPlayEnabled: Boolean,
) : PlaybackSeekDataProvider() {
companion object {
private const val SEEK_LENGTH = 10000L
}

private val imageRequests = mutableMapOf<Int, Disposable>()

override fun getSeekPositions(): LongArray {
if (!videoPlayerAdapter.canSeek()) return LongArray(0)

val duration = videoPlayerAdapter.duration
val size = ceil(duration.toDouble() / SEEK_LENGTH.toDouble()).toInt() + 1
return LongArray(size) { i -> min(i * SEEK_LENGTH, duration) }
}

override fun getThumbnail(index: Int, callback: ResultCallback) {
if (!trickPlayEnabled) return

val currentRequest = imageRequests[index]
if (currentRequest?.isDisposed == false) currentRequest.dispose()

val item = videoPlayerAdapter.currentlyPlayingItem
val mediaSource = videoPlayerAdapter.currentMediaSource
val mediaSourceId = mediaSource?.id?.toUUIDOrNull()
if (item == null || mediaSource == null || mediaSourceId == null) return

val trickPlayResolutions = item.trickplay?.get(mediaSource.id)
val trickPlayInfo = trickPlayResolutions?.values?.firstOrNull()
if (trickPlayInfo == null) return

val currentTimeMs = (index * SEEK_LENGTH).coerceIn(0, videoPlayerAdapter.duration)
val currentTile = currentTimeMs.floorDiv(trickPlayInfo.interval).toInt()

val tileSize = trickPlayInfo.tileWidth * trickPlayInfo.tileHeight
val tileOffset = currentTile % tileSize
val tileIndex = currentTile / tileSize

val tileOffsetX = tileOffset % trickPlayInfo.tileWidth
val tileOffsetY = tileOffset / trickPlayInfo.tileWidth
val offsetX = tileOffsetX * trickPlayInfo.width
val offsetY = tileOffsetY * trickPlayInfo.height

val url = api.trickplayApi.getTrickplayTileImageUrl(
itemId = item.id,
width = trickPlayInfo.width,
index = tileIndex,
mediaSourceId = mediaSourceId,
)

imageRequests[index] = imageLoader.enqueue(ImageRequest.Builder(context).apply {
data(url)
size(Size.ORIGINAL)
addHeader(
"Authorization",
AuthorizationHeaderBuilder.buildHeader(
api.clientInfo.name,
api.clientInfo.version,
api.deviceInfo.id,
api.deviceInfo.name,
api.accessToken
)
)

transformations(SubsetTransformation(offsetX, offsetY, trickPlayInfo.width, trickPlayInfo.height))

target(
onSuccess = { result ->
val bitmap = result.current.toBitmap()
callback.onThumbnailLoaded(bitmap, index)
}
)
}.build())
}

override fun reset() {
for (request in imageRequests.values) {
if (!request.isDisposed) request.dispose()
}
imageRequests.clear()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
import androidx.annotation.Nullable;
import androidx.leanback.app.PlaybackSupportFragment;

import org.jellyfin.androidtv.preference.UserPreferences;
import org.jellyfin.androidtv.ui.playback.CustomPlaybackOverlayFragment;
import org.jellyfin.androidtv.ui.playback.PlaybackController;
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer;
import org.jellyfin.sdk.api.client.ApiClient;

import coil.ImageLoader;
import kotlin.Lazy;
import timber.log.Timber;

Expand All @@ -21,6 +24,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment {
private VideoPlayerAdapter playerAdapter;
private boolean shouldShowOverlay = true;
private Lazy<PlaybackControllerContainer> playbackControllerContainer = inject(PlaybackControllerContainer.class);
private Lazy<ImageLoader> imageLoader = inject(ImageLoader.class);
private Lazy<ApiClient> api = inject(ApiClient.class);
private Lazy<UserPreferences> userPreferences = inject(UserPreferences.class);

@Override
public void onCreate(Bundle savedInstanceState) {
Expand Down Expand Up @@ -93,7 +99,8 @@ public void mediaInfoChanged() {

playerGlue.invalidatePlaybackControls();
playerGlue.setSeekEnabled(playerAdapter.canSeek());
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter) : null);
boolean enableTrickPlay = userPreferences.getValue().get(UserPreferences.Companion.getTrickPlayEnabled());
playerGlue.setSeekProvider(playerAdapter.canSeek() ? new CustomSeekProvider(playerAdapter, imageLoader.getValue(), api.getValue(), requireContext(), enableTrickPlay) : null);
recordingStateChanged();
playerAdapter.updateDuration();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.jellyfin.androidtv.util.Utils;
import org.jellyfin.androidtv.util.apiclient.StreamHelper;
import org.jellyfin.sdk.model.api.ChapterInfo;
import org.jellyfin.sdk.model.api.MediaSourceInfo;
import org.koin.java.KoinJavaComponent;

import java.util.List;
Expand Down Expand Up @@ -164,6 +165,10 @@ org.jellyfin.sdk.model.api.BaseItemDto getCurrentlyPlayingItem() {
return playbackController.getCurrentlyPlayingItem();
}

MediaSourceInfo getCurrentMediaSource() {
return playbackController.getCurrentMediaSource();
}

boolean hasChapters() {
org.jellyfin.sdk.model.api.BaseItemDto item = getCurrentlyPlayingItem();
List<ChapterInfo> chapters = item.getChapters();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ class DeveloperPreferencesScreen : OptionsFragment() {
}
}

checkbox {
setTitle(R.string.preference_enable_trickplay)
setContent(R.string.enable_playback_module_description)

bind(userPreferences, UserPreferences.trickPlayEnabled)
}

checkbox {
setTitle(R.string.prefer_exoplayer_ffmpeg)
setContent(R.string.prefer_exoplayer_ffmpeg_content)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.jellyfin.androidtv.util.coil

import android.graphics.Bitmap
import coil.size.Size
import coil.transform.Transformation

class SubsetTransformation(
private val x: Int,
private val y: Int,
private val width: Int,
private val height: Int,
) : Transformation {
override val cacheKey: String = "$x,$y,$width,$height"

override suspend fun transform(
input: Bitmap,
size: Size,
): Bitmap = Bitmap.createBitmap(input, x, y, width, height)
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class SdkPlaybackHelper(
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT,
ItemFields.TRICKPLAY,
)
)

Expand Down Expand Up @@ -119,7 +120,8 @@ class SdkPlaybackHelper(
ItemFields.PATH,
ItemFields.OVERVIEW,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CHILD_COUNT
ItemFields.CHILD_COUNT,
ItemFields.TRICKPLAY,
)
)

Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@
<string name="segment_type_preview">Previews</string>
<string name="segment_type_recap">Recaps</string>
<string name="segment_type_unknown">Unknown segments</string>
<string name="preference_enable_trickplay">Enable trickplay in video player</string>
<plurals name="seconds">
<item quantity="one">%1$s second</item>
<item quantity="other">%1$s seconds</item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class CustomSeekProviderTests : FunSpec({
every { canSeek() } returns true
every { duration } returns 30000L
}
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter)
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter, mockk(), mockk(), mockk(), false)

customSeekProvider.seekPositions shouldBe arrayOf(0L, 10000L, 20000L, 30000L)
}
Expand All @@ -21,7 +21,7 @@ class CustomSeekProviderTests : FunSpec({
every { canSeek() } returns true
every { duration } returns 45000L
}
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter)
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter, mockk(), mockk(), mockk(), false)

customSeekProvider.seekPositions shouldBe arrayOf(0L, 10000, 20000, 30000, 40000, 45000)
}
Expand All @@ -30,7 +30,7 @@ class CustomSeekProviderTests : FunSpec({
val videoPlayerAdapter = mockk<VideoPlayerAdapter> {
every { canSeek() } returns false
}
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter)
val customSeekProvider = CustomSeekProvider(videoPlayerAdapter, mockk(), mockk(), mockk(), false)

customSeekProvider.seekPositions.size shouldBe 0
}
Expand Down

0 comments on commit 77ef06d

Please sign in to comment.