From 63f95a8683e053178ddf2f786c50db76e13ad679 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 8 Sep 2026 23:12:21 +0200 Subject: [PATCH] fix: deliver stable fullscreen controls in mobile beta --- app/build.gradle.kts | 4 +- .../components/PlayerControlVisibilityTest.kt | 67 +++++++++++++++++++ .../components/PlayerControlsLayoutTest.kt | 60 +++++++++++++++++ .../components/PlayerGestureLayerTest.kt | 2 +- .../player/components/PlayerControls.kt | 10 ++- .../player/components/PlayerGestureLayer.kt | 4 +- 6 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0eb8c307..21b6ba65 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 = 10803 - versionName = "1.8.0-beta.3" + versionCode = 10804 + versionName = "1.8.0-beta.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt new file mode 100644 index 00000000..2b421491 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlVisibilityTest.kt @@ -0,0 +1,67 @@ +package dev.typetype.android.feature.player.components + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dev.typetype.android.feature.player.state.PlayerGestureState +import org.junit.Assert.assertTrue +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PlayerControlVisibilityTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun tapShowsControlsAfterAutomaticHideAndKeepsToggling() { + val visible = mutableStateOf(true) + val player = GestureTestPlayer(Looper.getMainLooper()) + val gestures = PlayerGestureState() + composeRule.setContent { + val currentVisibility = visible.value + PlayerGestureLayer( + player = player, + state = gestures, + onSingleTap = { visible.value = !currentVisibility }, + onAdjustBrightness = {}, + onAdjustVolume = {}, + isFullscreen = true, + modifier = Modifier.size(600.dp, 300.dp).testTag("gesture"), + ) + } + tap() + composeRule.runOnIdle { assertFalse(visible.value) } + composeRule.runOnIdle { visible.value = true } + composeRule.runOnIdle { visible.value = false } + tap() + composeRule.runOnIdle { assertTrue(visible.value) } + tap() + composeRule.runOnIdle { assertFalse(visible.value) } + tap() + composeRule.runOnIdle { + assertTrue(visible.value) + player.release() + } + } + + private fun tap() { + composeRule.onNodeWithTag("gesture").performTouchInput { + advanceEventTime(500L) + click() + } + composeRule.mainClock.advanceTimeBy(500L) + composeRule.waitForIdle() + } +} 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 6d19ec25..09dc85f6 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 @@ -4,6 +4,8 @@ import android.os.Looper import android.content.res.Configuration import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView +import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -15,6 +17,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.ViewCompat +import androidx.core.graphics.Insets +import android.graphics.Bitmap +import java.io.File import dev.typetype.android.R import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player @@ -33,6 +43,7 @@ import org.junit.runner.RunWith class PlayerControlsLayoutTest { @get:Rule val composeRule = createAndroidComposeRule() + private lateinit var controlsView: View @Test fun portraitControlsDoNotOverlapInsideShortVideoViewport() { @@ -49,6 +60,54 @@ class PlayerControlsLayoutTest { assertEquals(with(composeRule.density) { 74.dp.toPx() }, height, 1f) } + @Test + fun fullscreenTopControlsStayAnchoredWhenStatusBarsDisappear() { + composeRule.runOnUiThread { + val window = composeRule.activity.window + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowCompat.getInsetsController(window, window.decorView) + .show(WindowInsetsCompat.Type.statusBars()) + } + setControls(800.dp, 360.dp, fullscreen = true) + dispatchStatusBarInsets(40) + composeRule.waitForIdle() + val visibleTop = backButtonTopInViewport() + captureControls("status-bars-visible.png") + dispatchStatusBarInsets(0) + composeRule.waitForIdle() + captureControls("status-bars-hidden.png") + assertEquals(visibleTop, backButtonTopInViewport(), 1f) + } + + private fun dispatchStatusBarInsets(top: Int) { + composeRule.runOnUiThread { + ViewCompat.dispatchApplyWindowInsets( + controlsView, + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, top, 0, 0)) + .setVisible(WindowInsetsCompat.Type.statusBars(), top > 0) + .build(), + ) + } + } + + private fun backButtonTopInViewport(): Float { + val back = composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_back), + ).fetchSemanticsNode().boundsInRoot + val viewport = composeRule.onNodeWithTag(PLAYER_CONTROLS_VIEWPORT_TAG) + .fetchSemanticsNode().boundsInRoot + return back.top - viewport.top + } + + private fun captureControls(name: String) { + val bitmap = composeRule.onNodeWithTag(PLAYER_CONTROLS_VIEWPORT_TAG) + .captureToImage().asAndroidBitmap() + File(composeRule.activity.noBackupFilesDir, name).outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { setControls(720.dp, 405.dp, 720) @@ -71,6 +130,7 @@ class PlayerControlsLayoutTest { ) { val player = controlsLayoutPlayer() composeRule.setContent { + controlsView = LocalView.current val configuration = Configuration(LocalConfiguration.current).apply { smallestScreenWidthDp = smallestWidthDp } 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 658330ae..aa82b20c 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 @@ -277,7 +277,7 @@ class PlayerGestureLayerTest { } } -private class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { +internal class GestureTestPlayer(looper: Looper) : SimpleBasePlayer(looper) { private var positionMs = 20_000L private var playWhenReady = false private var parameters = PlaybackParameters.DEFAULT 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 8d6b9269..95e474af 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 @@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -86,7 +88,11 @@ fun PlayerControls( .testTag(PLAYER_TOP_CONTROLS_TAG) .then( if (isFullscreen) { - Modifier.windowInsetsPadding(WindowInsets.statusBars) + Modifier.windowInsetsPadding( + WindowInsets.displayCutout.only( + WindowInsetsSides.Top + WindowInsetsSides.Horizontal, + ), + ) } else { Modifier }, 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 066359ef..d60da04d 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 @@ -11,6 +11,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -48,6 +49,7 @@ fun PlayerGestureLayer( onBrightnessGestureStart: () -> Float = { state.brightnessFraction.floatValue }, onVolumeGestureStart: () -> Float = { state.volumeFraction.floatValue }, ) { + val currentOnSingleTap by rememberUpdatedState(onSingleTap) var savedSpeed by remember { mutableFloatStateOf(1f) } var holdSpeed by remember { mutableFloatStateOf(2f) } fun restoreSpeed() { @@ -182,7 +184,7 @@ fun PlayerGestureLayer( } .pointerInput(player, config) { detectTapGestures( - onTap = { onSingleTap() }, + onTap = { currentOnSingleTap() }, onDoubleTap = { offset -> val action = doubleTapAction(offset.x, size.width.toFloat()) if (!action.isEnabled(config.doubleTapSeekEnabled)) {