From cf3c00a1e11a95672511db276df00d360188f9d4 Mon Sep 17 00:00:00 2001 From: calebvetter <39844454+calebvetter@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:32:40 -0700 Subject: [PATCH] Add option for trickplay preview mode when D-Pad seeking (#1650) ## Description Adds a setting to allow the user to choose between the current D-Pad skip functionality, and a new simple trickplay overlay. The setting is added in Advanced, under the Playback section, called "D-Pad seek mode". The options are "Skip time" and "Trickplay previews". I left the default as "Skip time" since that's how it operates now, and nothing would change for users after this option is added unless they go and set it. When "Trickplay previews" is enabled, it shows an simple overlay when using the D-Pad to seek (without the full overlay open). The new simple overlay is the same implementation of the components in the full overlay: seekbar, trickplay images, current playhead time, remaining time. The components were pulled from the full overlay's inline UI code into their own reusable components, now used in both the full overlay and the simple overlay. #### Notes I could see there being an adjustment for the overlay hide delay. It's currently hard coded to 1.5s. This could be the default method, but it seems like preference is split as to whether people want this, or the minimalistic skipped seconds only version. ### Related issues This is directly in response to this issue, as well as my own desire for the feature :) https://github.com/damontecres/Wholphin/discussions/1084 It's also loosely related to this issue, in that it would solve it since it shows the playhead and remaining times. https://github.com/damontecres/Wholphin/discussions/1122 ### Testing Built and tested on an Amazon Fire TV Stick 4K Max. I wouldn't anticipate and compatibility or performance changes since it's a simple settings/UI change involving already-used UI components. ## Screenshots wholphin-20260630-155708 wholphin-20260630-155723 wholphin-20260630-153825 ## AI or LLM usage AI was used to generate the change, automate some testing, and provide information surrounding the change. I manually did some refactoring and cleanup to keep it clean and consistent with the rest of the code conventions. --------- Co-authored-by: Damontecres --- .../wholphin/preferences/AppPreference.kt | 14 ++++ .../preferences/AppPreferencesSerializer.kt | 1 + .../ui/detail/music/NowPlayingPage.kt | 2 + .../ui/playback/PlaybackKeyHandler.kt | 40 +++++++---- .../wholphin/ui/playback/PlaybackPage.kt | 59 ++++++++++++++- .../ui/playback/overlay/DpadSeekOverlay.kt | 71 +++++++++++++++++++ .../ui/playback/overlay/PlaybackControls.kt | 63 +++++++++------- .../ui/playback/overlay/PlaybackOverlay.kt | 37 ++-------- .../wholphin/ui/playback/overlay/SeekBar.kt | 24 +++++++ .../ui/playback/overlay/TrickplayPreview.kt | 63 ++++++++++++++++ app/src/main/proto/WholphinDataStore.proto | 6 ++ app/src/main/res/values/strings.xml | 8 +++ 12 files changed, 317 insertions(+), 71 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 12e39399..308ed909 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -661,6 +661,19 @@ sealed interface AppPreference { destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS), ) + val DpadSeekModePref = + AppChoicePreference( + title = R.string.d_pad_seek_mode_title, + defaultValue = DpadSeekMode.SKIP_TIME, + getter = { it.playbackPreferences.dpadSeekMode }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { dpadSeekMode = value } + }, + displayValues = R.array.dpad_seek_mode_options, + indexToValue = { DpadSeekMode.forNumber(it) }, + valueToIndex = { if (it != DpadSeekMode.UNRECOGNIZED) it.number else 0 }, + ) + val GlobalContentScale = AppChoicePreference( title = R.string.global_content_scale, @@ -1226,6 +1239,7 @@ val advancedPreferences = AppPreference.CinemaMode, AppPreference.GlobalContentScale, AppPreference.SkipSegments, + AppPreference.DpadSeekModePref, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, AppPreference.ResolutionSwitching, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index 1e84663d..abcee865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -55,6 +55,7 @@ class AppPreferencesSerializer AppPreference.RefreshRateSwitching.defaultValue resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue cinemaMode = AppPreference.CinemaMode.defaultValue + dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt index b61d6b89..f403c7a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt @@ -123,6 +123,7 @@ fun NowPlayingPage( // seekBack = preferences.playbackPreferences.skipBackMs.milliseconds, controllerViewState = controllerViewState, updateSkipIndicator = {}, + clearSkipIndicator = {}, skipBackOnResume = null, // skipBackOnResume = preferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, @@ -132,6 +133,7 @@ fun NowPlayingPage( }, onPlaybackDialogTypeClick = { }, getDurationMs = { player.duration }, + dpadSeekMode = preferences.playbackPreferences.dpadSeekMode, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 7209fbd9..f885826c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type import androidx.media3.common.Player import androidx.media3.common.util.Util +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import kotlin.time.Duration @@ -24,11 +25,15 @@ class PlaybackKeyHandler( private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, + private val clearSkipIndicator: () -> Unit, private val skipBackOnResume: Duration?, private val oneClickPause: Boolean, private val onInteraction: () -> Unit, private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, + private val isDpadSeekVisible: () -> Boolean = { false }, + private val onDpadSeek: (Long) -> Unit = { }, + private val dpadSeekMode: DpadSeekMode, ) { private var leftHandledByRepeat = false private var rightHandledByRepeat = false @@ -49,20 +54,19 @@ class PlaybackKeyHandler( if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { if (isLtr) { - updateSkipIndicator(-seekBack.inWholeMilliseconds) - player.seekBack(seekBack) + seekBy(-seekBack) } else { - updateSkipIndicator(seekForward.inWholeMilliseconds) - player.seekForward(seekForward) + seekBy(seekForward) } } else if (skipWithLeftRight && isSkipForward(it)) { if (isLtr) { - player.seekForward(seekForward) - updateSkipIndicator(seekForward.inWholeMilliseconds) + seekBy(seekForward) } else { - player.seekBack(seekBack) - updateSkipIndicator(seekBack.inWholeMilliseconds) + seekBy(-seekBack) } + } else if (isEnterKey(it) && isDpadSeekVisible()) { + // If d-pad seek bar is visible, hide it + clearSkipIndicator.invoke() } else if (oneClickPause && isEnterKey(it)) { val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) @@ -180,12 +184,24 @@ class PlaybackKeyHandler( ) { if (isBack) { val skipDuration = seekBack * multiplier - player.seekBack(skipDuration) - updateSkipIndicator(-skipDuration.inWholeMilliseconds) + seekBy(-skipDuration) } else { val skipDuration = seekForward * multiplier - player.seekForward(skipDuration) - updateSkipIndicator(skipDuration.inWholeMilliseconds) + seekBy(skipDuration) + } + } + + private fun seekBy(duration: Duration) { + val durationMs = duration.inWholeMilliseconds + if (dpadSeekMode == DpadSeekMode.TRICKPLAY) { + onDpadSeek.invoke(durationMs) + } else { + if (duration < Duration.ZERO) { + player.seekBack(-duration) + } else { + player.seekForward(duration) + } + updateSkipIndicator(durationMs) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 62eebd04..46c7753d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -11,6 +11,8 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box @@ -68,6 +70,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.mpv.MpvPlayer import com.github.damontecres.wholphin.preferences.AssPlaybackMode +import com.github.damontecres.wholphin.preferences.DpadSeekMode import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.skipBackOnResume @@ -78,6 +81,7 @@ import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.overlay.DpadSeekOverlay import com.github.damontecres.wholphin.ui.playback.overlay.PauseIndicator import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackAction import com.github.damontecres.wholphin.ui.playback.overlay.PlaybackOverlay @@ -221,6 +225,23 @@ fun PlaybackPageContent( skipIndicatorDuration += delta skipPosition = player.currentPosition } + val onDpadSeek: (Long) -> Unit = { deltaMs -> + if (skipIndicatorDuration == 0L) { + skipPosition = player.currentPosition + } + if ((skipIndicatorDuration > 0 && deltaMs < 0) || + (skipIndicatorDuration < 0 && deltaMs > 0) + ) { + skipIndicatorDuration = 0L + } + skipIndicatorDuration += deltaMs + skipPosition = + (skipPosition + deltaMs).coerceIn( + minimumValue = 0L, + maximumValue = player.duration.coerceAtLeast(0L), + ) + seekBarState.onValueChange(skipPosition) + } val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val keyHandler = remember(isLtr, preferences) { @@ -234,6 +255,7 @@ fun PlaybackPageContent( getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, + clearSkipIndicator = { skipIndicatorDuration = 0 }, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, onInteraction = viewModel::reportInteraction, oneClickPause = preferences.appPreferences.playbackPreferences.oneClickPause, @@ -242,6 +264,11 @@ fun PlaybackPageContent( viewModel.navigationManager.goBack() }, onPlaybackDialogTypeClick = { playbackDialog = it }, + isDpadSeekVisible = { + prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY && skipIndicatorDuration != 0L + }, + onDpadSeek = onDpadSeek, + dpadSeekMode = prefs.dpadSeekMode, ) } @@ -351,8 +378,38 @@ fun PlaybackPageContent( } } + AnimatedVisibility( + visible = + !controllerViewState.controlsVisible && + skipIndicatorDuration != 0L && + prefs.dpadSeekMode == DpadSeekMode.TRICKPLAY, + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + slideOutVertically { it }, + modifier = + Modifier + .align(Alignment.BottomCenter), + ) { + DpadSeekOverlay( + player = player, + seekPositionMs = skipPosition, + trickplayInfo = state.currentMediaInfo.trickPlayInfo, + trickplayUrlFor = viewModel::getTrickplayUrl, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + .fillMaxWidth(.95f), + ) + // Clear the overlay after a delay + LaunchedEffect(skipIndicatorDuration) { + delay(1.5.seconds) + skipIndicatorDuration = 0L + } + } + // If D-pad skipping, show the amount skipped in an animation - if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L) { + if (!controllerViewState.controlsVisible && skipIndicatorDuration != 0L && prefs.dpadSeekMode != DpadSeekMode.TRICKPLAY) { + // Skip time mode: show seek distance indicator SkipIndicator( durationMs = skipIndicatorDuration, onFinish = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt new file mode 100644 index 00000000..0a3ed634 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/DpadSeekOverlay.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import org.jellyfin.sdk.model.api.TrickplayInfo + +/** + * The compact overlay shown during D-Pad seeking in trickplay mode. + */ +@Composable +fun DpadSeekOverlay( + player: Player, + seekPositionMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + val durationMs = player.duration.coerceAtLeast(0L) + val seekProgressPercent = + if (durationMs > 0) { + (seekPositionMs.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + val bufferedProgress = + if (durationMs > 0) { + (player.bufferedPosition.toDouble() / durationMs).toFloat().coerceIn(0f, 1f) + } else { + 0f + } + + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Box( + modifier = Modifier.fillMaxWidth(), + ) { + TrickplayPreview( + seekProgressMs = seekPositionMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, + modifier = + Modifier + .align(Alignment.BottomStart) + .offsetByPercent(xPercentage = seekProgressPercent) + .padding(bottom = 8.dp), + ) + } + StaticSeekBarImpl( + progress = seekProgressPercent, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + SeekTimecodes( + positionMs = seekPositionMs, + durationMs = durationMs, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt index 79f44929..d8d43300 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackControls.kt @@ -272,32 +272,45 @@ fun SeekBar( seekBack = seekBack, seekForward = seekForward, ) - Row( + SeekTimecodes( + positionMs = position, + durationMs = player.duration, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - val resources = LocalResources.current - val positionSec = position / 1000 - val remainingSec = (player.duration - position) / 1000 - val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } - val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } - Text( - text = positionText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - Text( - text = remainingText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(8.dp), - ) - } + ) + } +} + +@Composable +fun SeekTimecodes( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val resources = LocalResources.current + val positionSec = positionMs / 1000 + val remainingSec = (durationMs - positionMs) / 1000 + val positionText = remember(positionSec) { resources.formatDuration(positionSec.seconds) } + val remainingText = remember(remainingSec) { "-${resources.formatDuration(remainingSec.seconds)}" } + Text( + text = positionText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) + Text( + text = remainingText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(8.dp), + ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt index 9af4a171..5f5891de 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/PlaybackOverlay.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,13 +40,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.media3.common.Player -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text import coil3.compose.AsyncImage import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter @@ -55,7 +51,6 @@ import com.github.damontecres.wholphin.data.model.PlaylistItem import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.TimeDisplay -import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.AnalyticsState import com.github.damontecres.wholphin.ui.playback.ControllerViewState @@ -65,7 +60,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.TrickplayInfo import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds /** * The overlay during playback showing controls, seek preview image, debug info, etc @@ -288,33 +282,10 @@ fun PlaybackOverlay( xPercentage = seekProgressPercent.coerceIn(0f, 1f), ).padding(bottom = controllerHeight - titleHeight - subtitleHeight), ) { - if (trickplayInfo != null) { - val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight - val index = - (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage - val imageUrl = remember(index) { trickplayUrlFor(index) } - - if (imageUrl != null) { - SeekPreviewImage( - modifier = Modifier, - previewImageUrl = imageUrl, - seekProgressMs = seekProgressMs, - trickPlayInfo = trickplayInfo, - ) - } - } - val resources = LocalResources.current - val seekText = remember(seekProgressMs / 1000L) { resources.formatDuration((seekProgressMs / 1000L).seconds) } - Text( - text = seekText, - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .background( - Color.Black.copy(alpha = 0.6f), - shape = RoundedCornerShape(4.dp), - ).padding(horizontal = 8.dp, vertical = 4.dp), + TrickplayPreview( + seekProgressMs = seekProgressMs, + trickplayInfo = trickplayInfo, + trickplayUrlFor = trickplayUrlFor, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt index d0dfec31..c03be433 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/SeekBar.kt @@ -108,6 +108,30 @@ fun SteppedSeekBarImpl( ) } +/** + * A read-only seek bar for showing progress without taking focus or handling D-Pad input. + * + * This is used by overlays where seeking is handled elsewhere and the bar is only a visual indicator. + */ +@Composable +fun StaticSeekBarImpl( + progress: Float, + durationMs: Long, + bufferedProgress: Float, + modifier: Modifier = Modifier, +) { + SeekBarDisplay( + enabled = false, + progress = progress, + bufferedProgress = bufferedProgress, + durationMs = durationMs, + onLeft = {}, + onRight = {}, + interactionSource = remember { MutableInteractionSource() }, + modifier = modifier, + ) +} + /** * A seek (or scrubber) bar which seeks forward or back a fixed amount of time per move */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt new file mode 100644 index 00000000..4871a77a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/overlay/TrickplayPreview.kt @@ -0,0 +1,63 @@ +package com.github.damontecres.wholphin.ui.playback.overlay + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.formatDuration +import org.jellyfin.sdk.model.api.TrickplayInfo +import kotlin.time.Duration.Companion.seconds + +@Composable +fun TrickplayPreview( + seekProgressMs: Long, + trickplayInfo: TrickplayInfo?, + trickplayUrlFor: (Int) -> String?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + if (trickplayInfo != null) { + val tilesPerImage = trickplayInfo.tileWidth * trickplayInfo.tileHeight + val index = + (seekProgressMs / trickplayInfo.interval).toInt() / tilesPerImage + val imageUrl = remember(index) { trickplayUrlFor(index) } + + if (imageUrl != null) { + SeekPreviewImage( + modifier = Modifier, + previewImageUrl = imageUrl, + seekProgressMs = seekProgressMs, + trickPlayInfo = trickplayInfo, + ) + } + } + val resources = LocalResources.current + val seekProgressSec = seekProgressMs / 1000L + val seekText = remember(seekProgressSec) { resources.formatDuration(seekProgressSec.seconds) } + Text( + text = seekText, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 4b1f6c34..021db051 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -87,6 +87,12 @@ message PlaybackPreferences { bool resolution_switching = 23; string external_player = 24; bool cinema_mode = 25; + DpadSeekMode dpad_seek_mode = 26; +} + +enum DpadSeekMode { + SKIP_TIME = 0; + TRICKPLAY = 1; } message HomePagePreferences{ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa33cf7b..5f5a54de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,9 @@ Add to playlist Pause with one click Press D-Pad center to pause/play + D-Pad seek mode + Skip time + Trickplay previews Italicize font Font Background @@ -847,6 +850,11 @@ Enable experimental settings? Experimental settings may be unstable!\n\n These may removed or changed in any release. Video tunneling + + @string/dpad_seek_mode_skip_time + @string/dpad_seek_mode_trickplay + + Blocklisted Unavailable Subtitle mode