From 09a401f984f219895c1f4b677a2df197c5cfb3da Mon Sep 17 00:00:00 2001 From: Hossein Msv Date: Sat, 26 Sep 2026 16:09:19 +0330 Subject: [PATCH] feat: progressive long-press seek with continuous scrubbing - Add continuous scrubbing when long-pressing next/previous buttons - Scrub rate accelerates from 5x to 10x after holding for 3 seconds - Refactor PlayerViewModel and PlaybackStateHolder to support precise continuous delta seeks - Update AnimatedPlaybackControls for seamless gesture handling with pointer input --- .../components/UnifiedPlayerSheetLayers.kt | 16 +++ .../external/ExternalPlayerOverlay.kt | 2 + .../player/AnimatedPlaybackControls.kt | 105 +++++++++++++++--- .../components/player/FullPlayerContent.kt | 8 ++ .../viewmodel/PlaybackStateHolder.kt | 15 +++ .../presentation/viewmodel/PlayerViewModel.kt | 4 + 6 files changed, 135 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt index 2aed352222..0eb3c71fb8 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetLayers.kt @@ -210,6 +210,12 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( val onSeek = remember(playerViewModel) { playerViewModel::seekTo } val onNext = remember(playerViewModel) { playerViewModel::nextSong } val onPrevious = remember(playerViewModel) { playerViewModel::previousSong } + val onSeekForward = remember(playerViewModel) { + { delta: Long -> playerViewModel.seekByDelta(delta) } + } + val onSeekBackward = remember(playerViewModel) { + { delta: Long -> playerViewModel.seekByDelta(delta) } + } val onCollapse = remember(playerViewModel) { { playerViewModel.collapsePlayerSheet() } } @@ -247,6 +253,8 @@ internal fun BoxScope.UnifiedPlayerMiniAndFullLayers( onSeek = onSeek, onNext = onNext, onPrevious = onPrevious, + onSeekForward = onSeekForward, + onSeekBackward = onSeekBackward, onCollapse = onCollapse, onShowQueueClicked = onShowQueueClicked, onQueueDragStart = onQueueDragStart, @@ -313,6 +321,12 @@ internal fun UnifiedPlayerPrewarmLayer( val onSeek = remember(playerViewModel) { playerViewModel::seekTo } val onNext = remember(playerViewModel) { playerViewModel::nextSong } val onPrevious = remember(playerViewModel) { playerViewModel::previousSong } + val onSeekForward = remember(playerViewModel) { + { delta: Long -> playerViewModel.seekByDelta(delta) } + } + val onSeekBackward = remember(playerViewModel) { + { delta: Long -> playerViewModel.seekByDelta(delta) } + } val onShuffleToggle = remember(playerViewModel) { { playerViewModel.toggleShuffle() } } val onRepeatToggle = remember(playerViewModel) { playerViewModel::cycleRepeatMode } val onFavoriteToggle = remember(playerViewModel) { playerViewModel::toggleFavorite } @@ -348,6 +362,8 @@ internal fun UnifiedPlayerPrewarmLayer( onSeek = onSeek, onNext = onNext, onPrevious = onPrevious, + onSeekForward = onSeekForward, + onSeekBackward = onSeekBackward, onCollapse = {}, onShowCastClicked = {}, onShuffleToggle = onShuffleToggle, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt index 45d88900d5..7c9245d5b4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/external/ExternalPlayerOverlay.kt @@ -302,6 +302,8 @@ fun ExternalPlayerOverlay( onPrevious = playerViewModel::previousSong, onPlayPause = playerViewModel::playPause, onNext = playerViewModel::nextSong, + onSeekForward = { delta -> playerViewModel.seekByDelta(delta) }, + onSeekBackward = { delta -> playerViewModel.seekByDelta(delta) }, height = 76.dp, pressAnimationSpec = controlSpatialSpec, colorOtherButtons = skipContainer, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt index 75b5287987..6c975a8da9 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/AnimatedPlaybackControls.kt @@ -6,8 +6,10 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -40,16 +42,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.theveloper.pixelplay.presentation.components.LocalMaterialTheme +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape private enum class PlaybackButtonType { NONE, PREVIOUS, PLAY_PAUSE, NEXT } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable fun AnimatedPlaybackControls( isPlayingProvider: () -> Boolean, @@ -57,6 +62,8 @@ fun AnimatedPlaybackControls( onPlayPause: () -> Unit, onNext: () -> Unit, modifier: Modifier = Modifier, + onSeekForward: ((Long) -> Unit)? = null, + onSeekBackward: ((Long) -> Unit)? = null, height: Dp = 90.dp, baseWeight: Float = 1f, expansionWeight: Float = 1.1f, @@ -147,20 +154,54 @@ fun AnimatedPlaybackControls( animationSpec = pressAnimationSpec, label = "prevWeight" ) + var seekBackwardJob by remember { mutableStateOf(null) } Box( modifier = Modifier .weight(prevWeight) .fillMaxHeight() .clip(CircleShape) .background(colorPreviousButton) - .clickable { - lastClicked = PlaybackButtonType.PREVIOUS - clickTrigger++ - coroutineScope.launch { - delay(180) - onPrevious() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + if (event.changes.all { !it.pressed }) { + seekBackwardJob?.cancel() + seekBackwardJob = null + } + } } - }, + } + .combinedClickable( + onClick = { + lastClicked = PlaybackButtonType.PREVIOUS + clickTrigger++ + coroutineScope.launch { + delay(180) + onPrevious() + } + }, + onLongClick = if (onSeekBackward != null) { + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + seekBackwardJob = coroutineScope.launch { + var elapsed = 0L + val tickRate = 100L + while (true) { + delay(tickRate) + elapsed += tickRate + val accelerated = elapsed >= 3000L + val skipAmount = if (accelerated) 1000L else 500L + + if (elapsed % 400L == 0L) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + onSeekBackward(-skipAmount) + } + } + } + } else null + ), contentAlignment = Alignment.Center ) { Icon( @@ -220,20 +261,54 @@ fun AnimatedPlaybackControls( animationSpec = pressAnimationSpec, label = "nextWeight" ) + var seekForwardJob by remember { mutableStateOf(null) } Box( modifier = Modifier .weight(nextWeight) .fillMaxHeight() .clip(CircleShape) .background(colorNextButton) - .clickable { - lastClicked = PlaybackButtonType.NEXT - clickTrigger++ - coroutineScope.launch { - delay(180) - onNext() + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Final) + if (event.changes.all { !it.pressed }) { + seekForwardJob?.cancel() + seekForwardJob = null + } + } } - }, + } + .combinedClickable( + onClick = { + lastClicked = PlaybackButtonType.NEXT + clickTrigger++ + coroutineScope.launch { + delay(180) + onNext() + } + }, + onLongClick = if (onSeekForward != null) { + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + seekForwardJob = coroutineScope.launch { + var elapsed = 0L + val tickRate = 100L + while (true) { + delay(tickRate) + elapsed += tickRate + val accelerated = elapsed >= 3000L + val skipAmount = if (accelerated) 1000L else 500L + + if (elapsed % 400L == 0L) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + onSeekForward(skipAmount) + } + } + } + } else null + ), contentAlignment = Alignment.Center ) { Icon( diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 8a30f6f41d..bfe05755b5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -220,6 +220,8 @@ fun FullPlayerContent( onSeek: (Long) -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, + onSeekForward: ((Long) -> Unit)? = null, + onSeekBackward: ((Long) -> Unit)? = null, onCollapse: () -> Unit, onShowQueueClicked: () -> Unit, onQueueDragStart: () -> Unit, @@ -555,6 +557,8 @@ fun FullPlayerContent( onPrevious = onPreviousWithOptimisticCarousel, onPlayPause = onPlayPause, onNext = onNextWithOptimisticCarousel, + onSeekForward = onSeekForward, + onSeekBackward = onSeekBackward, transportPlayPauseColors = transportPlayPauseColors, transportSkipColors = transportSkipButtonColors, isShuffleEnabledProvider = isShuffleEnabledProvider, @@ -1118,6 +1122,8 @@ private fun FullPlayerControlsSection( onPrevious: () -> Unit, onPlayPause: () -> Unit, onNext: () -> Unit, + onSeekForward: ((Long) -> Unit)? = null, + onSeekBackward: ((Long) -> Unit)? = null, transportPlayPauseColors: TransportButtonColors, transportSkipColors: TransportButtonColors, isShuffleEnabledProvider: () -> Boolean, @@ -1162,6 +1168,8 @@ private fun FullPlayerControlsSection( onPrevious = onPrevious, onPlayPause = onPlayPause, onNext = onNext, + onSeekForward = onSeekForward, + onSeekBackward = onSeekBackward, height = 80.dp, pressAnimationSpec = controlSpatialSpec, releaseDelay = 220L, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt index 1ef56cb287..955046dd0f 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlaybackStateHolder.kt @@ -486,6 +486,21 @@ class PlaybackStateHolder @Inject constructor( } } + /** + * Seeks relative to the current position by [deltaMs] milliseconds. + * Positive values seek forward, negative values seek backward. + * The resulting position is clamped to `[0, totalDuration]`. + */ + fun seekByDelta(deltaMs: Long) { + val current = _currentPosition.value + val duration = _stablePlayerState.value.totalDuration.takeIf { it > 0L } + ?: _stablePlayerState.value.currentSong?.duration + ?: return + val safeDuration = duration.coerceAtLeast(0L) + val target = (current + deltaMs).coerceAtMost(safeDuration).coerceAtLeast(0L) + seekTo(target) + } + fun previousSong() { val castSession = castStateHolder.castSession.value if (castSession != null && castSession.remoteMediaClient != null) { diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt index 4ddadbedea..9813a6273a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt @@ -2455,6 +2455,10 @@ class PlayerViewModel @Inject constructor( playbackStateHolder.previousSong() } + fun seekByDelta(deltaMs: Long) { + playbackStateHolder.seekByDelta(deltaMs) + } + private fun startProgressUpdates() { playbackStateHolder.startProgressUpdates() }