mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
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:
parent
55466f803c
commit
fd9063fbad
5 changed files with 279 additions and 27 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue