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
<img width="1920" height="1080" alt="wholphin-20260630-155708"
src="https://github.com/user-attachments/assets/52046cc2-7413-4f66-89dd-355464fa0f91"
/>
<img width="1920" height="1080" alt="wholphin-20260630-155723"
src="https://github.com/user-attachments/assets/57a4a271-9e44-4d2a-bea3-ce5c9cdc731a"
/>
<img width="1920" height="1080" alt="wholphin-20260630-153825"
src="https://github.com/user-attachments/assets/2cf664ff-3db5-4a2e-8626-10219946669c"
/>


## 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 <damontecres@gmail.com>
This commit is contained in:
calebvetter 2026-07-23 12:32:40 -07:00 • committed by GitHub
parent d2bf5cd1e2
commit cf3c00a1e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 317 additions and 71 deletions

View file

@ -661,6 +661,19 @@ sealed interface AppPreference<Pref, T> {
destination = Destination.Settings(PreferenceScreenOption.SKIP_SEGMENTS),
)
val DpadSeekModePref =
AppChoicePreference<AppPreferences, DpadSeekMode>(
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<AppPreferences, PrefContentScale>(
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,

View file

@ -55,6 +55,7 @@ class AppPreferencesSerializer
AppPreference.RefreshRateSwitching.defaultValue
resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue
cinemaMode = AppPreference.CinemaMode.defaultValue
dpadSeekMode = AppPreference.DpadSeekModePref.defaultValue
overrides =
PlaybackOverrides

View file

@ -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,
)
}

View file

@ -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)
}
}

View file

@ -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 = {

View file

@ -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(),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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,
)
}
}

View file

@ -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
*/

View file

@ -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),
)
}
}

View file

@ -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{

View file

@ -180,6 +180,9 @@
<string name="add_to_playlist">Add to playlist</string>
<string name="one_click_pause">Pause with one click</string>
<string name="one_click_pause_summary_on">Press D-Pad center to pause/play</string>
<string name="d_pad_seek_mode_title">D-Pad seek mode</string>
<string name="dpad_seek_mode_skip_time">Skip time</string>
<string name="dpad_seek_mode_trickplay">Trickplay previews</string>
<string name="italic_font">Italicize font</string>
<string name="font">Font</string>
<string name="background">Background</string>
@ -847,6 +850,11 @@
<string name="confirm_enable_experimental_title">Enable experimental settings?</string>
<string name="confirm_enable_experimental_body">Experimental settings may be unstable!\n\n These may removed or changed in any release.</string>
<string name="video_tunneling">Video tunneling</string>
<string-array name="dpad_seek_mode_options">
<item>@string/dpad_seek_mode_skip_time</item>
<item>@string/dpad_seek_mode_trickplay</item>
</string-array>
<string name="blocklisted">Blocklisted</string>
<string name="unavailable">Unavailable</string>
<string name="subtitle_mode">Subtitle mode</string>