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
## 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