Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental Trickplay implementation #4172

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading