diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d2992881..7daa50b0 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 = 10801 - versionName = "1.8.0-beta.1" + versionCode = 10802 + versionName = "1.8.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index dc92b42e..6d19ec25 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -1,6 +1,9 @@ package dev.typetype.android.feature.player.components import android.os.Looper +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -37,9 +40,18 @@ class PlayerControlsLayoutTest { assertControlsDoNotOverlap() } + @Test + fun landscapePhoneKeepsNormalFullscreenControlSize() { + setControls(800.dp, 360.dp, smallestWidthDp = 360, fullscreen = true) + assertControlsDoNotOverlap() + val height = composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) + .fetchSemanticsNode().boundsInRoot.height + assertEquals(with(composeRule.density) { 74.dp.toPx() }, height, 1f) + } + @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { - setControls(720.dp, 405.dp) + setControls(720.dp, 405.dp, 720) assertControlsDoNotOverlap() composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) .assertHeightIsAtLeast(96.dp) @@ -51,22 +63,33 @@ class PlayerControlsLayoutTest { ).assertHeightIsAtLeast(64.dp) } - private fun setControls(width: Dp, height: Dp) { + private fun setControls( + width: Dp, + height: Dp, + smallestWidthDp: Int = 360, + fullscreen: Boolean = false, + ) { val player = controlsLayoutPlayer() composeRule.setContent { - Box( - Modifier - .size(width = width, height = height) - .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), - ) { - PlayerControls( - player = player, - title = "Portrait controls", - onNavigateBack = {}, - isPipAvailable = true, - chaptersAvailable = true, - modifier = Modifier.matchParentSize(), - ) + val configuration = Configuration(LocalConfiguration.current).apply { + smallestScreenWidthDp = smallestWidthDp + } + CompositionLocalProvider(LocalConfiguration provides configuration) { + Box( + Modifier + .size(width = width, height = height) + .testTag(PLAYER_CONTROLS_VIEWPORT_TAG), + ) { + PlayerControls( + player = player, + title = "Portrait controls", + onNavigateBack = {}, + isPipAvailable = true, + isFullscreen = fullscreen, + chaptersAvailable = true, + modifier = Modifier.matchParentSize(), + ) + } } } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt new file mode 100644 index 00000000..a3c8d4e5 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControlSizing.kt @@ -0,0 +1,7 @@ +package dev.typetype.android.feature.player.components + +internal fun useExpandedPlayerControls( + smallestWindowWidthDp: Int, + playerWidthDp: Float, + playerHeightDp: Float, +): Boolean = smallestWindowWidthDp >= 600 && playerWidthDp >= 600 && playerHeightDp >= 300 diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index acb53053..8d6b9269 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R @@ -51,7 +52,9 @@ fun PlayerControls( ) { BoxWithConstraints(modifier = modifier) { val compactControls = !isFullscreen && maxHeight < COMPACT_CONTROLS_HEIGHT - val expandedControls = maxWidth >= 600.dp && maxHeight >= 300.dp + val expandedControls = useExpandedPlayerControls( + LocalConfiguration.current.smallestScreenWidthDp, maxWidth.value, maxHeight.value, + ) if (!timelineScrubbing) { TopScrim( compact = compactControls, 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 14d43cea..d8e32138 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,12 +3,13 @@ package dev.typetype.android.feature.player.components import kotlin.math.abs import kotlin.math.sign -internal fun proportionalSeekTarget(startMs: Long, dragX: Float, width: Float, durationMs: Long): Long { - if (durationMs <= 0 || width <= 0 || !dragX.isFinite()) return startMs - val fraction = (dragX / width).coerceIn(-1f, 1f) - val rangeMs = minOf(durationMs, 180_000L) - val accelerated = fraction * (0.35f + 0.65f * abs(fraction)) - return (startMs + (accelerated * rangeMs).toLong()).coerceIn(0L, durationMs) +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) { 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 66bba828..0684d0d1 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,6 +72,7 @@ fun PlayerGestureLayer( var mode = DragMode.None val speedSteps = HoldSpeedSteps(24.dp.toPx()) var speedDragY = 0f + var seekDragX = 0f state.dragMode.value = DragMode.None state.seekDragStartMs.longValue = player.currentPosition state.seekDragTargetMs.longValue = player.currentPosition @@ -140,7 +141,10 @@ fun PlayerGestureLayer( state.volumeFraction.floatValue = onVolumeGestureStart() state.volumeOverlayActive.value = true } - DragMode.Seek -> state.seekDragOverlayActive.value = true + DragMode.Seek -> { + state.seekDragStartMs.longValue = player.currentPosition + state.seekDragOverlayActive.value = true + } DragMode.FullscreenEnter -> Unit DragMode.FullscreenExit -> Unit DragMode.None -> Unit @@ -148,13 +152,13 @@ fun PlayerGestureLayer( } if (mode != DragMode.None) { change.consume() + if (mode == DragMode.Seek) seekDragX += delta.x handleDragMode( player = player, state = state, mode = mode, delta = delta, - totalDragX = totalDrag.x, - viewportWidth = size.width.toFloat(), + totalDragX = seekDragX, levelDragRangePx = levelDragRangePx( size.width.toFloat(), size.height.toFloat(), @@ -250,7 +254,6 @@ private fun handleDragMode( mode: DragMode, delta: Offset, totalDragX: Float, - viewportWidth: Float, levelDragRangePx: Float, onAdjustBrightness: (Float) -> Unit, onAdjustVolume: (Float) -> Unit, @@ -275,8 +278,8 @@ private fun handleDragMode( onAdjustVolume(next) } DragMode.Seek -> { - state.seekDragTargetMs.longValue = proportionalSeekTarget( - state.seekDragStartMs.longValue, totalDragX, viewportWidth, player.duration, + state.seekDragTargetMs.longValue = swipeSeekTarget( + state.seekDragStartMs.longValue, totalDragX, player.duration, ) } DragMode.FullscreenEnter, @@ -293,7 +296,8 @@ internal fun levelDragRangePx(width: Float, height: Float): Float = minOf(width, height) * LEVEL_DRAG_VIEW_FRACTION internal fun pickDragMode(dragAmount: Offset, startX: Float, width: Float): DragMode = when { - abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.Seek + abs(dragAmount.x) > abs(dragAmount.y) * 1.5f -> DragMode.Seek + abs(dragAmount.x) > abs(dragAmount.y) -> DragMode.None startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y < 0f -> DragMode.FullscreenEnter startX in (width * 0.35f)..(width * 0.65f) && dragAmount.y > 0f -> DragMode.FullscreenExit startX < width / 2f -> DragMode.Brightness diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index 931ec824..a5bf0ac0 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.semantics.setProgress import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -282,7 +283,7 @@ internal fun PlayerSeekScrubOverlay( modifier: Modifier = Modifier, ) { BoxWithConstraints(modifier = modifier) { - val expanded = maxWidth >= 600.dp + val expanded = LocalConfiguration.current.smallestScreenWidthDp >= 600 && maxWidth >= 600.dp TimelineTrack( positionMs = positionMs, durationMs = player.duration.coerceAtLeast(0L), diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerControlSizingTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerControlSizingTest.kt new file mode 100644 index 00000000..03b01f09 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerControlSizingTest.kt @@ -0,0 +1,21 @@ +package dev.typetype.android.feature.player.components + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PlayerControlSizingTest { + @Test fun `landscape phone never uses tablet controls`() { + assertFalse(useExpandedPlayerControls(411, 891f, 411f)) + assertFalse(useExpandedPlayerControls(360, 800f, 360f)) + } + + @Test fun `tablet keeps larger controls when player has room`() { + assertTrue(useExpandedPlayerControls(800, 800f, 450f)) + assertTrue(useExpandedPlayerControls(800, 1280f, 800f)) + } + + @Test fun `small tablet viewport stays compact`() { + assertFalse(useExpandedPlayerControls(800, 500f, 281f)) + } +} 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 658672d5..c167a615 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 @@ -1,28 +1,30 @@ package dev.typetype.android.feature.player.components import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class PlayerDragResponseTest { - @Test fun `seek scales with viewport rather than pixel density`() { - assertEquals( - proportionalSeekTarget(100_000, 100f, 1000f, 600_000), - proportionalSeekTarget(100_000, 200f, 2000f, 600_000), - ) + @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 `larger movement accelerates and reversing returns to anchor`() { - val small = proportionalSeekTarget(100_000, 100f, 1000f, 600_000) - 100_000 - val large = proportionalSeekTarget(100_000, 200f, 1000f, 600_000) - 100_000 - assertTrue(large > small * 2) - assertEquals(100_000L, proportionalSeekTarget(100_000, 0f, 1000f, 600_000)) - assertEquals(0L, proportionalSeekTarget(0, -500f, 1000f, 600_000)) - assertEquals(600_000L, proportionalSeekTarget(600_000, 500f, 1000f, 600_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, proportionalSeekTarget(15_000, 100f, 1000f, -1)) + assertEquals(15_000L, swipeSeekTarget(15_000, 100f, -1)) } @Test fun `hold starts at two and ignores jitter`() { diff --git a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt index e1e6fc4e..94ddffd4 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/components/PlayerLevelGestureTest.kt @@ -6,6 +6,12 @@ import dev.typetype.android.feature.player.state.DragMode import org.junit.Test class PlayerLevelGestureTest { + @Test + fun `ambiguous diagonal does not capture seek`() { + assertEquals(DragMode.None, pickDragMode(Offset(30f, 25f), 100f, 300f)) + assertEquals(DragMode.Seek, pickDragMode(Offset(40f, 25f), 100f, 300f)) + } + @Test fun `upward drag raises the level gradually`() { assertEquals(0.75f, adjustLevelFraction(0.5f, -150f, 600f), 0.001f)