Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,53 @@ class PlayerGestureLayerTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

@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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +28,7 @@ internal fun PlayerSurfaceChrome(
seekPreviewPositionMs: Long?,
seekDragOverlayVisible: Boolean,
seekDragPositionMs: Long,
fineSeeking: Boolean,
isFullscreen: Boolean,
isInPip: Boolean,
controlsAllowedByProgress: Boolean,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class PlayerGestureState {
val seekDragStartMs: MutableLongState = mutableLongStateOf(0L)
val seekDragTargetMs: MutableLongState = mutableLongStateOf(0L)
val seekDragOverlayActive: MutableState<Boolean> = mutableStateOf(false)
val fineSeeking: MutableState<Boolean> = mutableStateOf(false)
val longPressBoostActive: MutableState<Boolean> = mutableStateOf(false)

fun showSeekHint(side: GestureSide, seconds: Int) {
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings_seek.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="player_fine_seek_position">Fine seeking · %1$s</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}