Hold-to-seek acceleration for playback DPAD and seekbar (#900)

## Description
- Adds hold-to-seek acceleration for left/right DPAD input during
playback skip handling.
- Reuses one shared, duration-aware acceleration profile for both
playback DPAD and seekbar skipping.
- Extracts acceleration logic into a shared helper to keep playback and
seekbar behavior consistent.
- Slows the acceleration ramp (about one-third) to improve control
during longer holds.
- Preserves tap-on-release behavior: single taps seek on key release,
while holds seek on repeated key-down without an extra release step.
- Normalizes unknown/unset duration values to use the shortest-content
acceleration profile.
- Added unit coverage for shared seek acceleration math to prevent
accidental regression in ramp thresholds.

### Related issues
Supersedes #846  
Incorporates #784  
Closes #522

### Testing
Tested on Nvidia Shield Pro 2019.

- Quick left/right tap still performs normal skip behavior.
- Holding left/right seeks repeatedly with progressive acceleration.
- Seekbar hold uses the same acceleration profile as playback hold.
- Releasing after a hold does not trigger an extra seek step.

## AI or LLM usage
Codex 5.3 and Claude Opus 4.6 were used for implementation, debugging,
and cross-review. Changes were manually validated on device.

---------

Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com>
This commit is contained in:
voc0der 2026-02-28 19:35:02 +00:00 committed by GitHub
parent 55466f803c
commit fd9063fbad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 279 additions and 27 deletions

View file

@ -20,6 +20,7 @@ class PlaybackKeyHandler(
private val skipWithLeftRight: Boolean,
private val seekBack: Duration,
private val seekForward: Duration,
private val getDurationMs: () -> Long,
private val controllerViewState: ControllerViewState,
private val updateSkipIndicator: (Long) -> Unit,
private val skipBackOnResume: Duration?,
@ -28,15 +29,22 @@ class PlaybackKeyHandler(
private val onStop: () -> Unit,
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
) {
private var leftHandledByRepeat = false
private var rightHandledByRepeat = false
fun onKeyEvent(it: KeyEvent): Boolean {
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
var result = true
if (!controlsEnabled) {
result = false
return false
} else if (handleHoldSkip(it)) {
return true
} else if (it.type != KeyEventType.KeyUp) {
result = false
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
return false
}
var result = true
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) {
updateSkipIndicator(-seekBack.inWholeMilliseconds)
@ -111,4 +119,84 @@ class PlaybackKeyHandler(
}
return result
}
private fun handleHoldSkip(event: KeyEvent): Boolean {
if (
controllerViewState.controlsVisible ||
!skipWithLeftRight ||
(!isSkipBack(event) && !isSkipForward(event))
) {
return false
}
val isBack = isSkipBack(event)
return when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
if (repeatCount > 0) {
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
setHandledByRepeat(isBack = isBack, handled = false)
return true
}
val multiplier =
calculateSeekAccelerationMultiplier(
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
durationMs = normalizedDurationMs(),
)
setHandledByRepeat(isBack = isBack, handled = true)
seekWithMultiplier(isBack = isBack, multiplier = multiplier)
} else {
setHandledByRepeat(isBack = isBack, handled = false)
}
true
}
KeyEventType.KeyUp -> {
if (!handledByRepeat(isBack = isBack)) {
seekWithMultiplier(isBack = isBack, multiplier = 1)
}
setHandledByRepeat(isBack = isBack, handled = false)
true
}
else -> {
false
}
}
}
private fun seekWithMultiplier(
isBack: Boolean,
multiplier: Int,
) {
if (isBack) {
val skipDuration = seekBack * multiplier
player.seekBack(skipDuration)
updateSkipIndicator(-skipDuration.inWholeMilliseconds)
} else {
val skipDuration = seekForward * multiplier
player.seekForward(skipDuration)
updateSkipIndicator(skipDuration.inWholeMilliseconds)
}
}
private fun setHandledByRepeat(
isBack: Boolean,
handled: Boolean,
) {
if (isBack) {
leftHandledByRepeat = handled
} else {
rightHandledByRepeat = handled
}
}
private fun handledByRepeat(isBack: Boolean): Boolean =
if (isBack) {
leftHandledByRepeat
} else {
rightHandledByRepeat
}
private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L)
}

View file

@ -235,6 +235,7 @@ fun PlaybackPageContent(
skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
getDurationMs = { player.duration.coerceAtLeast(0L) },
controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,

View file

@ -0,0 +1,52 @@
package com.github.damontecres.wholphin.ui.playback
internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12
/**
* Shared seek acceleration profile for hold-to-seek behavior.
* Keep this in sync anywhere directional key repeat seeking is handled.
*/
fun calculateSeekAccelerationMultiplier(
repeatCount: Int,
durationMs: Long,
): Int {
if (repeatCount <= 0 || durationMs <= 0L) return 1
// Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds.
val scaledRepeatCount = repeatCount / 3
if (scaledRepeatCount <= 0) return 1
val durationMinutes = durationMs / 60_000L
return when {
durationMinutes < 30 -> {
if (scaledRepeatCount < 30) 1 else 2
}
durationMinutes < 90 -> {
when {
scaledRepeatCount < 13 -> 1
scaledRepeatCount < 50 -> 2
scaledRepeatCount < 75 -> 3
else -> 4
}
}
durationMinutes < 150 -> {
when {
scaledRepeatCount < 20 -> 1
scaledRepeatCount < 40 -> 2
scaledRepeatCount < 60 -> 4
else -> 6
}
}
else -> {
when {
scaledRepeatCount < 20 -> 1
scaledRepeatCount < 40 -> 3
scaledRepeatCount < 60 -> 6
else -> 10
}
}
}
}

View file

@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration
@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
enabled = enabled,
progress = progressToUse,
bufferedProgress = bufferedProgress,
onLeft = {
durationMs = durationMs,
onLeft = { multiplier ->
controllerViewState.pulseControls()
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
hasSeeked = true
seek(seekProgress)
},
onRight = {
onRight = { multiplier ->
controllerViewState.pulseControls()
seekProgress = (progressToUse + offset).coerceAtMost(1f)
seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
hasSeeked = true
seek(seekProgress)
},
@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
enabled = enabled,
progress = (progressToUse.toDouble() / durationMs).toFloat(),
bufferedProgress = bufferedProgress,
onLeft = {
durationMs = durationMs,
onLeft = { multiplier ->
controllerViewState.pulseControls()
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
seekPositionMs =
(progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
hasSeeked = true
onSeek(seekPositionMs)
},
onRight = {
onRight = { multiplier ->
controllerViewState.pulseControls()
seekPositionMs =
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
(progressToUse + seekForward.inWholeMilliseconds * multiplier)
.coerceAtMost(durationMs)
hasSeeked = true
onSeek(seekPositionMs)
},
@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
fun SeekBarDisplay(
progress: Float,
bufferedProgress: Float,
onLeft: () -> Unit,
onRight: () -> Unit,
durationMs: Long,
onLeft: (Int) -> Unit,
onRight: (Int) -> Unit,
interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier,
enabled: Boolean = true,
@ -158,14 +162,13 @@ fun SeekBarDisplay(
val onSurface = MaterialTheme.colorScheme.onSurface
val isFocused by interactionSource.collectIsFocusedAsState()
var leftHandledByRepeat by remember { mutableStateOf(false) }
var rightHandledByRepeat by remember { mutableStateOf(false) }
val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
Canvas(
modifier =
Modifier
@ -173,24 +176,79 @@ fun SeekBarDisplay(
.height(animatedIndicatorHeight)
.padding(horizontal = 4.dp)
.onPreviewKeyEvent { event ->
val trigger =
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
when (event.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
if (trigger) onLeft.invoke()
when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
if (repeatCount > 0) {
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
leftHandledByRepeat = false
return@onPreviewKeyEvent true
}
leftHandledByRepeat = true
onLeft.invoke(
calculateSeekAccelerationMultiplier(
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
durationMs = durationMs,
),
)
} else {
leftHandledByRepeat = false
}
}
KeyEventType.KeyUp -> {
if (!leftHandledByRepeat) {
onLeft.invoke(1)
}
leftHandledByRepeat = false
}
else -> {
return@onPreviewKeyEvent false
}
}
return@onPreviewKeyEvent true
}
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
if (trigger) onRight.invoke()
when (event.type) {
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
if (repeatCount > 0) {
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
rightHandledByRepeat = false
return@onPreviewKeyEvent true
}
rightHandledByRepeat = true
onRight.invoke(
calculateSeekAccelerationMultiplier(
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
durationMs = durationMs,
),
)
} else {
rightHandledByRepeat = false
}
}
KeyEventType.KeyUp -> {
if (!rightHandledByRepeat) {
onRight.invoke(1)
}
rightHandledByRepeat = false
}
else -> {
return@onPreviewKeyEvent false
}
}
return@onPreviewKeyEvent true
}
}
false
}.handleDPadKeyEvents(
onLeft = onLeft,
onRight = onRight,
).focusable(enabled = enabled, interactionSource = interactionSource),
}.focusable(enabled = enabled, interactionSource = interactionSource),
onDraw = {
val yOffset = size.height.div(2)
drawLine(

View file

@ -0,0 +1,53 @@
package com.github.damontecres.wholphin.ui.playback
import org.junit.Assert.assertEquals
import org.junit.Test
class SeekAccelerationTest {
@Test
fun returnsOneWhenNotRepeating() {
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L))
}
@Test
fun unknownDurationDoesNotAccelerate() {
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L))
assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L))
}
@Test
fun shortContentHasTwoTiers() {
val shortDurationMs = 20L * 60_000L
assertEquals(
1,
calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs),
)
assertEquals(
2,
calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs),
)
}
@Test
fun mediumContentEscalatesAcrossAllTiers() {
val mediumDurationMs = 60L * 60_000L
assertEquals(
1,
calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs),
)
assertEquals(
2,
calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs),
)
assertEquals(
3,
calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs),
)
assertEquals(
4,
calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs),
)
}
}