diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt index 9cb4239d54..7e39d2e662 100644 --- a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt +++ b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt @@ -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 { diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/CustomSeekProvider.kt b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/CustomSeekProvider.kt index 25ce490bdb..4e2061c4c3 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/CustomSeekProvider.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/CustomSeekProvider.kt @@ -1,16 +1,33 @@ 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() + override fun getSeekPositions(): LongArray { if (!videoPlayerAdapter.canSeek()) return LongArray(0) @@ -18,4 +35,70 @@ class CustomSeekProvider( 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() + } } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/LeanbackOverlayFragment.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/LeanbackOverlayFragment.java index c409d6e962..42bb1e30a6 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/LeanbackOverlayFragment.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/LeanbackOverlayFragment.java @@ -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; @@ -21,6 +24,9 @@ public class LeanbackOverlayFragment extends PlaybackSupportFragment { private VideoPlayerAdapter playerAdapter; private boolean shouldShowOverlay = true; private Lazy playbackControllerContainer = inject(PlaybackControllerContainer.class); + private Lazy imageLoader = inject(ImageLoader.class); + private Lazy api = inject(ApiClient.class); + private Lazy userPreferences = inject(UserPreferences.class); @Override public void onCreate(Bundle savedInstanceState) { @@ -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(); } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/VideoPlayerAdapter.java b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/VideoPlayerAdapter.java index 49570454c2..41b39d32ef 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/VideoPlayerAdapter.java +++ b/app/src/main/java/org/jellyfin/androidtv/ui/playback/overlay/VideoPlayerAdapter.java @@ -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; @@ -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 chapters = item.getChapters(); diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt index db32ac6cce..217458b576 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/preference/screen/DeveloperPreferencesScreen.kt @@ -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) diff --git a/app/src/main/java/org/jellyfin/androidtv/util/coil/SubsetTransformation.kt b/app/src/main/java/org/jellyfin/androidtv/util/coil/SubsetTransformation.kt new file mode 100644 index 0000000000..c49b775b53 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/coil/SubsetTransformation.kt @@ -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) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/util/sdk/SdkPlaybackHelper.kt b/app/src/main/java/org/jellyfin/androidtv/util/sdk/SdkPlaybackHelper.kt index 82ef5a0e1b..0ce99c506e 100644 --- a/app/src/main/java/org/jellyfin/androidtv/util/sdk/SdkPlaybackHelper.kt +++ b/app/src/main/java/org/jellyfin/androidtv/util/sdk/SdkPlaybackHelper.kt @@ -91,6 +91,7 @@ class SdkPlaybackHelper( ItemFields.OVERVIEW, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.CHILD_COUNT, + ItemFields.TRICKPLAY, ) ) @@ -119,7 +120,8 @@ class SdkPlaybackHelper( ItemFields.PATH, ItemFields.OVERVIEW, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, - ItemFields.CHILD_COUNT + ItemFields.CHILD_COUNT, + ItemFields.TRICKPLAY, ) ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1299959c38..2646732685 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -528,6 +528,7 @@ Previews Recaps Unknown segments + Enable TrickPlay in legacy player %1$s second %1$s seconds diff --git a/app/src/test/kotlin/ui/playback/overlay/CustomSeekProviderTests.kt b/app/src/test/kotlin/ui/playback/overlay/CustomSeekProviderTests.kt index 0fdb179ff8..29917b5b39 100644 --- a/app/src/test/kotlin/ui/playback/overlay/CustomSeekProviderTests.kt +++ b/app/src/test/kotlin/ui/playback/overlay/CustomSeekProviderTests.kt @@ -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) } @@ -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) } @@ -30,7 +30,7 @@ class CustomSeekProviderTests : FunSpec({ val videoPlayerAdapter = mockk { every { canSeek() } returns false } - val customSeekProvider = CustomSeekProvider(videoPlayerAdapter) + val customSeekProvider = CustomSeekProvider(videoPlayerAdapter, mockk(), mockk(), mockk(), false) customSeekProvider.seekPositions.size shouldBe 0 }