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 = 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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(),
)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -140,21 +141,24 @@ 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
}
}
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(),
Expand Down Expand Up @@ -250,7 +254,6 @@ private fun handleDragMode(
mode: DragMode,
delta: Offset,
totalDragX: Float,
viewportWidth: Float,
levelDragRangePx: Float,
onAdjustBrightness: (Float) -> Unit,
onAdjustVolume: (Float) -> Unit,
Expand All @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -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`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down