diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7daa50b0..0eb8c307 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10802 - versionName = "1.8.0-beta.2" + versionCode = 10803 + versionName = "1.8.0-beta.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt index f0e0e235..658330ae 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerGestureLayerTest.kt @@ -39,6 +39,53 @@ class PlayerGestureLayerTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun seekingCanSwitchToFinePrecisionWithoutJumpingOrChangingLevels() { + val player = GestureTestPlayer(Looper.getMainLooper()) + val state = PlayerGestureState() + val levels = AtomicInteger() + composeRule.setContent { + PlayerGestureLayer( + player = player, + state = state, + onSingleTap = {}, + onAdjustBrightness = { levels.incrementAndGet() }, + onAdjustVolume = { levels.incrementAndGet() }, + isFullscreen = true, + config = PlayerGestureConfig(swipeSeekEnabled = true), + modifier = Modifier.size(300.dp, 180.dp).testTag(GESTURE_TAG), + ) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + down(Offset(width * 0.25f, center.y)) + moveBy(Offset(width * 0.25f, 0f), delayMillis = 80) + } + var coarseTarget = 0L + composeRule.runOnIdle { + coarseTarget = state.seekDragTargetMs.longValue + assertTrue(coarseTarget > 120_000) + assertEquals(20_000L, player.currentPosition) + } + val fineDrop = with(composeRule.density) { 60.dp.toPx() } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(0f, fineDrop), delayMillis = 80) + } + composeRule.runOnIdle { + assertTrue(state.fineSeeking.value) + assertEquals(coarseTarget, state.seekDragTargetMs.longValue) + } + composeRule.onNodeWithTag(GESTURE_TAG).performTouchInput { + moveBy(Offset(10f, 0f), delayMillis = 80) + up() + } + composeRule.runOnIdle { + assertTrue(player.currentPosition > coarseTarget) + assertTrue(player.currentPosition <= coarseTarget + 1000) + assertEquals(0, levels.get()) + player.release() + } + } + @Test fun longPressDragChangesSpeedWithoutChangingLevelsAndRestoresOnRelease() { val player = GestureTestPlayer(Looper.getMainLooper()) diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt index d8e32138..e1376737 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerDragResponse.kt @@ -3,15 +3,6 @@ package dev.typetype.android.feature.player.components import kotlin.math.abs import kotlin.math.sign -internal fun swipeSeekTarget(startMs: Long, dragX: Float, durationMs: Long): Long { - if (durationMs <= 0 || !dragX.isFinite()) return startMs - val normalTravelMs = abs(dragX.toDouble()) * 100.0 - val travelMs = minOf(normalTravelMs, 60_000.0) + - (normalTravelMs - 60_000.0).coerceAtLeast(0.0) * 10.0 - return (startMs.toDouble() + sign(dragX) * travelMs) - .coerceIn(0.0, durationMs.toDouble()).toLong() -} - internal class HoldSpeedSteps(private val stepPx: Float) { var factor: Float = 2f private set diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt index 0684d0d1..066359ef 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerGestureLayer.kt @@ -72,7 +72,7 @@ fun PlayerGestureLayer( var mode = DragMode.None val speedSteps = HoldSpeedSteps(24.dp.toPx()) var speedDragY = 0f - var seekDragX = 0f + var seekGesture: TimelineSeekGesture? = null state.dragMode.value = DragMode.None state.seekDragStartMs.longValue = player.currentPosition state.seekDragTargetMs.longValue = player.currentPosition @@ -143,6 +143,10 @@ fun PlayerGestureLayer( } DragMode.Seek -> { state.seekDragStartMs.longValue = player.currentPosition + seekGesture = TimelineSeekGesture( + player.currentPosition, player.duration, + size.width.toFloat(), density, + ) state.seekDragOverlayActive.value = true } DragMode.FullscreenEnter -> Unit @@ -152,13 +156,15 @@ fun PlayerGestureLayer( } if (mode != DragMode.None) { change.consume() - if (mode == DragMode.Seek) seekDragX += delta.x + seekGesture?.let { seek -> + state.seekDragTargetMs.longValue = seek.move(delta.x, totalDrag.y) + if (state.fineSeeking.value != seek.fineSeeking) onGestureFeedback() + state.fineSeeking.value = seek.fineSeeking + } handleDragMode( - player = player, state = state, mode = mode, delta = delta, - totalDragX = seekDragX, levelDragRangePx = levelDragRangePx( size.width.toFloat(), size.height.toFloat(), @@ -249,11 +255,9 @@ private val PlayerGestureConfig.seekIncrementMs: Long get() = doubleTapSeekSeconds.coerceIn(5, 30) * 1_000L private fun handleDragMode( - player: Player, state: PlayerGestureState, mode: DragMode, delta: Offset, - totalDragX: Float, levelDragRangePx: Float, onAdjustBrightness: (Float) -> Unit, onAdjustVolume: (Float) -> Unit, @@ -277,11 +281,7 @@ private fun handleDragMode( state.volumeFraction.floatValue = next onAdjustVolume(next) } - DragMode.Seek -> { - state.seekDragTargetMs.longValue = swipeSeekTarget( - state.seekDragStartMs.longValue, totalDragX, player.duration, - ) - } + DragMode.Seek, DragMode.FullscreenEnter, DragMode.FullscreenExit, DragMode.None, @@ -310,4 +310,5 @@ private fun resetDragState(state: PlayerGestureState) { state.brightnessOverlayActive.value = false state.volumeOverlayActive.value = false state.seekDragOverlayActive.value = false + state.fineSeeking.value = false } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index 7b4387c3..e09dc642 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -241,6 +241,7 @@ internal fun PlayerSurfaceBox( .takeIf { gestureState.seekDragOverlayActive.value }, seekDragOverlayVisible = gestureState.seekDragOverlayActive.value, seekDragPositionMs = gestureState.seekDragTargetMs.longValue, + fineSeeking = gestureState.fineSeeking.value, isFullscreen = isFullscreen, isInPip = isInPip, controlsAllowedByProgress = controlsAllowedByProgress, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt index bb7a8267..e7cfd99c 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt @@ -14,6 +14,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import dev.typetype.android.R import androidx.media3.session.MediaController import dev.typetype.android.domain.stream.SponsorBlockSegment import dev.typetype.android.feature.player.state.ResizeMode @@ -26,6 +28,7 @@ internal fun PlayerSurfaceChrome( seekPreviewPositionMs: Long?, seekDragOverlayVisible: Boolean, seekDragPositionMs: Long, + fineSeeking: Boolean, isFullscreen: Boolean, isInPip: Boolean, controlsAllowedByProgress: Boolean, @@ -53,7 +56,14 @@ internal fun PlayerSurfaceChrome( modifier = Modifier.align(Alignment.BottomCenter), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(text = formatPlayerTime(seekDragPositionMs), color = Color.White) + Text( + text = if (fineSeeking) { + stringResource(R.string.player_fine_seek_position, formatPlayerTime(seekDragPositionMs)) + } else { + formatPlayerTime(seekDragPositionMs) + }, + color = Color.White, + ) PlayerSeekScrubOverlay( player = player, positionMs = seekDragPositionMs, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt b/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt new file mode 100644 index 00000000..d74fb019 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/TimelineSeekGesture.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.feature.player.components + +internal class TimelineSeekGesture( + startMs: Long, + private val durationMs: Long, + private val viewportWidthPx: Float, + private val density: Float, +) { + private var position = startMs.toDouble().coerceIn(0.0, durationMs.coerceAtLeast(0).toDouble()) + var fineSeeking = false + private set + + fun move(deltaX: Float, downwardTravelPx: Float): Long { + if (durationMs <= 0 || viewportWidthPx <= 0 || density <= 0 || + !viewportWidthPx.isFinite() || !density.isFinite() || + !deltaX.isFinite() || !downwardTravelPx.isFinite() + ) return position.toLong() + + val downwardDp = downwardTravelPx / density + fineSeeking = when { + downwardDp >= 48f -> true + downwardDp <= 32f -> false + else -> fineSeeking + } + val normalMsPerPixel = durationMs.toDouble() / viewportWidthPx + val msPerPixel = if (fineSeeking) { + minOf(normalMsPerPixel / 10.0, 100.0 / density) + } else { + normalMsPerPixel + } + position = (position + deltaX * msPerPixel).coerceIn(0.0, durationMs.toDouble()) + return position.toLong() + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt index a8a62c06..a340b329 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/state/PlayerGestureState.kt @@ -33,6 +33,7 @@ class PlayerGestureState { val seekDragStartMs: MutableLongState = mutableLongStateOf(0L) val seekDragTargetMs: MutableLongState = mutableLongStateOf(0L) val seekDragOverlayActive: MutableState = mutableStateOf(false) + val fineSeeking: MutableState = mutableStateOf(false) val longPressBoostActive: MutableState = mutableStateOf(false) fun showSeekHint(side: GestureSide, seconds: Int) { diff --git a/app/src/main/res/values/strings_seek.xml b/app/src/main/res/values/strings_seek.xml new file mode 100644 index 00000000..b2f1da3f --- /dev/null +++ b/app/src/main/res/values/strings_seek.xml @@ -0,0 +1,4 @@ + + + Fine seeking ยท %1$s + diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt index c167a615..305abd03 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerDragResponseTest.kt @@ -4,29 +4,6 @@ import org.junit.Assert.assertEquals import org.junit.Test class PlayerDragResponseTest { - @Test fun `seek uses the same sensitivity for short and long videos`() { - assertEquals(30_000L, swipeSeekTarget(20_000, 100f, 120_000)) - assertEquals(30_000L, swipeSeekTarget(20_000, 100f, 7_200_000)) - } - - @Test fun `seek accelerates tenfold after sixty seconds of travel`() { - assertEquals(159_900L, swipeSeekTarget(100_000, 599f, 600_000)) - assertEquals(160_000L, swipeSeekTarget(100_000, 600f, 600_000)) - assertEquals(161_000L, swipeSeekTarget(100_000, 601f, 600_000)) - assertEquals(260_000L, swipeSeekTarget(100_000, 700f, 600_000)) - assertEquals(140_000L, swipeSeekTarget(300_000, -700f, 600_000)) - } - - @Test fun `seek clamps to media and reversing returns to the anchor`() { - assertEquals(100_000L, swipeSeekTarget(100_000, 0f, 600_000)) - assertEquals(0L, swipeSeekTarget(0, -500f, 600_000)) - assertEquals(600_000L, swipeSeekTarget(600_000, 500f, 600_000)) - } - - @Test fun `invalid duration leaves playback unchanged`() { - assertEquals(15_000L, swipeSeekTarget(15_000, 100f, -1)) - } - @Test fun `hold starts at two and ignores jitter`() { val steps = HoldSpeedSteps(24f) assertEquals(2f, steps.update(-23f)) diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/TimelineSeekGestureTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/TimelineSeekGestureTest.kt new file mode 100644 index 00000000..4d14b5fd --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/components/TimelineSeekGestureTest.kt @@ -0,0 +1,54 @@ +package dev.typetype.android.feature.player.components + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TimelineSeekGestureTest { + @Test fun `full width traverses both short and twelve hour videos`() { + for (duration in listOf(120_000L, 7_200_000L, 43_200_000L)) { + val seek = TimelineSeekGesture(0, duration, 1000f, 1f) + assertEquals(duration / 4, seek.move(250f, 0f)) + assertEquals(duration, seek.move(750f, 0f)) + assertEquals(0L, seek.move(-1000f, 0f)) + } + } + + @Test fun `same fraction has same effect at different densities`() { + assertEquals( + TimelineSeekGesture(0, 7_200_000, 1000f, 1f).move(250f, 0f), + TimelineSeekGesture(0, 7_200_000, 2000f, 2f).move(500f, 0f), + ) + } + + @Test fun `vertical precision changes do not move the destination`() { + val seek = TimelineSeekGesture(0, 7_200_000, 1000f, 1f) + val coarse = seek.move(250f, 0f) + assertEquals(coarse, seek.move(0f, 48f)) + assertTrue(seek.fineSeeking) + assertEquals(coarse, seek.move(0f, 40f)) + assertTrue(seek.fineSeeking) + assertEquals(coarse + 1000, seek.move(10f, 48f)) + assertEquals(coarse + 1000, seek.move(0f, 32f)) + assertFalse(seek.fineSeeking) + assertEquals(coarse + 73_000, seek.move(10f, 0f)) + } + + @Test fun `fine seeking stays precise for long media and high density`() { + val seek = TimelineSeekGesture(1_000_000, 43_200_000, 2000f, 2f) + assertEquals(1_001_000L, seek.move(20f, 96f)) + } + + @Test fun `edge clamp allows immediate reversal`() { + val seek = TimelineSeekGesture(0, 120_000, 1000f, 1f) + assertEquals(0L, seek.move(-500f, 0f)) + assertEquals(1200L, seek.move(10f, 0f)) + } + + @Test fun `invalid movement is ignored`() { + val seek = TimelineSeekGesture(60_000, 120_000, 1000f, 1f) + assertEquals(60_000L, seek.move(Float.NaN, 0f)) + assertEquals(60_000L, seek.move(10f, Float.POSITIVE_INFINITY)) + } +}