feat: add drag-to-seek support on video progress indicator

The progress scrubber can now be dragged to seek through the video,
in addition to the existing tap-to-seek. During drag, the scrubber
enlarges for visual feedback and tracks the finger position in
real-time. Seeking is applied on drag end.

https://claude.ai/code/session_01QvYAvNdC35zGgkdpnujFn2
This commit is contained in:
Claude
2026-03-24 04:01:27 +00:00
parent 7874893938
commit 6e0e8cf00f
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -30,7 +31,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -103,21 +106,43 @@ private fun HorizontalLinearProgressIndicator(
scrubberColor: Color = playedColor, scrubberColor: Color = playedColor,
rectHeightDp: Dp = 4.dp, rectHeightDp: Dp = 4.dp,
) { ) {
var isDragging by remember { mutableStateOf(false) }
var dragProgress by remember { mutableFloatStateOf(0f) }
Canvas( Canvas(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = rectHeightDp * 2.5f) .padding(horizontal = rectHeightDp * 2.5f)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures { offset -> detectTapGestures { offset ->
// Capture the exact position
onSeek(offset.x / this.size.width.toFloat()) onSeek(offset.x / this.size.width.toFloat())
} }
}.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
isDragging = true
dragProgress = (offset.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDrag = { change, _ ->
change.consume()
dragProgress = (change.position.x / this.size.width.toFloat()).coerceIn(0f, 1f)
},
onDragEnd = {
onSeek(dragProgress)
isDragging = false
},
onDragCancel = {
isDragging = false
},
)
}.padding(vertical = rectHeightDp * 2) }.padding(vertical = rectHeightDp * 2)
.height(rectHeightDp) .height(rectHeightDp)
.onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, .onSizeChanged { (w, _) -> onLayoutWidthChanged(w) },
) { ) {
val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f) val displayProgress = if (isDragging) dragProgress else currentPositionProgress()
val positionX = (displayProgress * size.width).coerceAtLeast(0f)
val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f) val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f)
val scrubberRadius = if (isDragging) size.height * 3f else size.height * 2f
drawRect(unplayedColor, size = Size(size.width, size.height)) drawRect(unplayedColor, size = Size(size.width, size.height))
drawRect(bufferedColor, size = Size(bufferX, size.height)) drawRect(bufferedColor, size = Size(bufferX, size.height))
@@ -125,7 +150,7 @@ private fun HorizontalLinearProgressIndicator(
drawCircle( drawCircle(
color = scrubberColor, color = scrubberColor,
radius = size.height * 2f, radius = scrubberRadius,
center = Offset(x = positionX, y = size.height / 2.0f), center = Offset(x = positionX, y = size.height / 2.0f),
) )
} }