From 1453f2d40331e0d404c830cc81415c3ab13f1798 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:09:43 +0000 Subject: [PATCH 1/5] Show controls on key press and handle media keys Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/PlayerKeys.kt | 91 +++++ .../com/theoplayer/android/ui/UIController.kt | 8 + .../theoplayer/android/ui/PlayerKeysTest.kt | 323 ++++++++++++++++++ 3 files changed, 422 insertions(+) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt create mode 100644 ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt new file mode 100644 index 0000000..ac56aee --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -0,0 +1,91 @@ +package com.theoplayer.android.ui + +import android.view.KeyEvent as NativeKeyEvent +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import com.theoplayer.android.api.player.Player + +/** Handles keyboard and remote keys for a player UI. */ +@InternalUiApi +fun Modifier.handlePlayerKeys( + player: Player?, + controlsVisible: () -> Boolean, + showControls: () -> Unit, + seekOffset: Int = 10 +): Modifier = onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + + val nativeKeyEvent = event.nativeKeyEvent + when { + nativeKeyEvent.keyCode.isMediaKey() -> { + showControls() + handleMediaKey(player, nativeKeyEvent.keyCode, seekOffset) + true + } + nativeKeyEvent.isSystem -> false + else -> { + val wasVisible = controlsVisible() + showControls() + !wasVisible + } + } +} + +/** Checks whether a key code represents a media transport key. */ +private fun Int.isMediaKey(): Boolean = when (this) { + NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + NativeKeyEvent.KEYCODE_MEDIA_PLAY, + NativeKeyEvent.KEYCODE_MEDIA_PAUSE, + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD, + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> true + else -> false +} + +/** Performs the action for a media transport key. */ +private fun handleMediaKey(player: Player?, keyCode: Int, seekOffset: Int) { + when (keyCode) { + NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayPause(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY -> playOrReplay(player) + NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) + } +} + +/** Toggles playback, replaying when paused at the end. */ +private fun togglePlayPause(player: Player?) { + if (player == null) return + if (player.isPaused) { + playOrReplay(player) + } else { + player.pause() + } +} + +/** Starts playback or replays ended media. */ +private fun playOrReplay(player: Player?) { + if (player == null) return + if (player.isEnded) { + replay(player) + } else { + player.play() + } +} + +/** Starts playback from the ended state, like [PlayButtonState.onClick]. */ +private fun replay(player: Player) { + player.play() +} + +/** Seeks by [seekOffset], clamped to the media duration. */ +private fun seekBy(player: Player?, seekOffset: Int) { + if (player == null || player.ads.isPlaying) return + val duration = player.duration + if (!duration.isNaN()) { + player.currentTime = (player.currentTime + seekOffset).coerceIn(0.0, duration) + } +} diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 20b647d..030aeae 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -306,6 +306,14 @@ internal fun UIControllerImpl( tapCount++ } ) + .handlePlayerKeys( + player = nativePlayer, + controlsVisible = { controlsVisible.value }, + showControls = { + forceControlsHidden = false + tapCount++ + } + ) ) { CompositionLocalProvider( LocalPlayer provides player, diff --git a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt new file mode 100644 index 0000000..ce6010c --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt @@ -0,0 +1,323 @@ +package com.theoplayer.android.ui + +import android.view.KeyEvent +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.player.Player +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(InternalUiApi::class) +class PlayerKeysTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun hiddenControlsShowAndConsumeDpadKey() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { false }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + true + } + ) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + assertEquals(1, showControlsCalls) + assertFalse(childSawKey) + } + + @Test + fun visibleControlsShowAndPassDpadKeyToFocusedChild() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { true }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + true + } + ) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + assertEquals(1, showControlsCalls) + assertTrue(childSawKey) + } + + @Test + fun mediaPlayPausePausesWhilePlaying() { + val player = mockPlayer(paused = false) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.pause() } + } + + @Test + fun mediaPlayPausePlaysWhilePaused() { + val player = mockPlayer(paused = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.play() } + } + + @Test + fun mediaPlayPauseReplaysWhenPausedAndEnded() { + val player = mockPlayer(paused = true, ended = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) + verify(exactly = 1) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun mediaPlayReplaysWhenEnded() { + val player = mockPlayer(paused = true, ended = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_PLAY)) + verify(exactly = 1) { player.play() } + } + + @Test + fun mediaFastForwardSeeksForwardByTenSeconds() { + val player = mockPlayer(currentTime = 40.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 1) { player.currentTime = 50.0 } + } + + @Test + fun mediaRewindSeeksBackwardByTenSeconds() { + val player = mockPlayer(currentTime = 40.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_REWIND)) + verify(exactly = 1) { player.currentTime = 30.0 } + } + + @Test + fun mediaFastForwardIsClampedToDuration() { + val player = mockPlayer(currentTime = 95.0, duration = 100.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 1) { player.currentTime = 100.0 } + } + + @Test + fun mediaRewindIsClampedToZero() { + val player = mockPlayer(currentTime = 5.0, duration = 100.0) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_REWIND)) + verify(exactly = 1) { player.currentTime = 0.0 } + } + + @Test + fun mediaSeekIsSkippedDuringAds() { + val player = mockPlayer(currentTime = 40.0, adPlaying = true) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun mediaSeekIsSkippedForUnknownDuration() { + val player = mockPlayer(currentTime = 40.0, duration = Double.NaN) + setContent(player = player) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun backPassesThroughWithoutShowingControls() { + var showControlsCalls = 0 + var childSawKey = false + setContent( + controlsVisible = { false }, + showControls = { showControlsCalls++ }, + onChildKey = { + childSawKey = true + false + } + ) + + dispatchKey(KeyEvent.KEYCODE_BACK) + assertEquals(0, showControlsCalls) + assertTrue(childSawKey) + } + + @Test + fun keysFromThePlayerControlsComposeViewReachTheContainerModifier() { + val player = mockPlayer() + val focusRequester = FocusRequester() + val showControlsCalls = mutableIntStateOf(0) + var focused = false + var childSawKey = false + lateinit var theoplayerView: THEOplayerView + + composeRule.setContent { + val renderCount = showControlsCalls.intValue + val view = remember { THEOplayerView(composeRule.activity) } + theoplayerView = view + + Box(Modifier.fillMaxSize()) { + PlayerContainer( + theoplayerView = view, + modifier = Modifier + .fillMaxSize() + .handlePlayerKeys( + player = player, + controlsVisible = { true }, + showControls = { showControlsCalls.intValue++ } + ) + ) { + Box( + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .onFocusChanged { focused = it.isFocused } + .onKeyEvent { + childSawKey = true + true + } + .focusable() + ) + } + Spacer(Modifier.size(renderCount.dp)) + } + } + + composeRule.waitUntil(10_000) { + val playerUiContainer = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + (0 until playerUiContainer.childCount) + .any { playerUiContainer.getChildAt(it) is ComposeView } + } + composeRule.runOnIdle { focusRequester.requestFocus() } + composeRule.waitUntil(10_000) { focused } + + val container = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + val hostedComposeView = (0 until container.childCount) + .map(container::getChildAt) + .filterIsInstance() + .single() + val hostedComposeViewIndex = container.indexOfChild(hostedComposeView) + var addedViews = 0 + var removedViews = 0 + container.setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + addedViews++ + } + + override fun onChildViewRemoved(parent: View?, child: View?) { + removedViews++ + } + }) + + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + ) + composeRule.waitForIdle() + + assertEquals(1, showControlsCalls.intValue) + assertTrue(focused) + assertTrue(childSawKey) + assertSame(hostedComposeView, container.getChildAt(hostedComposeViewIndex)) + assertEquals(0, addedViews) + assertEquals(0, removedViews) + } + + private fun setContent( + player: Player? = null, + controlsVisible: () -> Boolean = { false }, + showControls: () -> Unit = {}, + onChildKey: (ComposeKeyEvent) -> Boolean = { false } + ) { + val focusRequester = FocusRequester() + composeRule.setContent { + Box( + Modifier + .fillMaxSize() + .handlePlayerKeys(player, controlsVisible, showControls) + ) { + Box( + Modifier + .size(100.dp) + .focusRequester(focusRequester) + .onKeyEvent(onChildKey) + .focusable() + ) + } + } + composeRule.runOnIdle { focusRequester.requestFocus() } + } + + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, keyCode) + ) + + private fun mockPlayer( + paused: Boolean = true, + ended: Boolean = false, + currentTime: Double = 40.0, + duration: Double = 100.0, + adPlaying: Boolean = false + ): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns adPlaying + val player = mockk(relaxed = true) + every { player.isPaused } returns paused + every { player.isEnded } returns ended + every { player.ads } returns ads + every { player.currentTime } returns currentTime + every { player.duration } returns duration + return player + } +} From 12e35885a16fe554dbcf238082b66f60e5bbb665 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:11:25 +0000 Subject: [PATCH 2/5] Simplify media play key handling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/PlayerKeys.kt | 21 +++---------------- .../theoplayer/android/ui/PlayerKeysTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt index ac56aee..5796cf9 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -49,38 +49,23 @@ private fun Int.isMediaKey(): Boolean = when (this) { private fun handleMediaKey(player: Player?, keyCode: Int, seekOffset: Int) { when (keyCode) { NativeKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> togglePlayPause(player) - NativeKeyEvent.KEYCODE_MEDIA_PLAY -> playOrReplay(player) + NativeKeyEvent.KEYCODE_MEDIA_PLAY -> player?.play() NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) } } -/** Toggles playback, replaying when paused at the end. */ +/** Toggles between play and pause. */ private fun togglePlayPause(player: Player?) { if (player == null) return if (player.isPaused) { - playOrReplay(player) + player.play() } else { player.pause() } } -/** Starts playback or replays ended media. */ -private fun playOrReplay(player: Player?) { - if (player == null) return - if (player.isEnded) { - replay(player) - } else { - player.play() - } -} - -/** Starts playback from the ended state, like [PlayButtonState.onClick]. */ -private fun replay(player: Player) { - player.play() -} - /** Seeks by [seekOffset], clamped to the media duration. */ private fun seekBy(player: Player?, seekOffset: Int) { if (player == null || player.ads.isPlaying) return diff --git a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt index ce6010c..3335239 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/PlayerKeysTest.kt @@ -98,7 +98,7 @@ class PlayerKeysTest { } @Test - fun mediaPlayPauseReplaysWhenPausedAndEnded() { + fun mediaPlayPauseCallsPlayWhenPausedAndEnded() { val player = mockPlayer(paused = true, ended = true) setContent(player = player) @@ -108,7 +108,7 @@ class PlayerKeysTest { } @Test - fun mediaPlayReplaysWhenEnded() { + fun mediaPlayCallsPlayWhenEnded() { val player = mockPlayer(paused = true, ended = true) setContent(player = player) From b7388739bab1e35fcd4152ea2396c9ceb69959d8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:14:04 +0000 Subject: [PATCH 3/5] Add changelog entry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fdf464..11b47ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * 🏠 `UIController` and `DefaultUI` now use the experimental state holders internally. ([#116](https://github.com/THEOplayer/android-ui/pull/116)) * 🚀 Added `UIController(theoplayerView)` and `DefaultUI(theoplayerView)`, which use the experimental state holders and don't create a `Player`. `UIControllerScope.player` is only created when accessed. Use `rememberTHEOplayerView(config)` to create a `THEOplayerView` that follows the lifecycle of the composition. `UIController(config)` and `DefaultUI(config)` no longer create a `Player` either. ([#119](https://github.com/THEOplayer/android-ui/pull/119)) * 🐛 Fixed `AudioTrackList`, `SubtitleTrackList`, `QualityList` and `PlaybackRateList` not scrolling to the selected item, and not announcing it to accessibility services. ([#127](https://github.com/THEOplayer/android-ui/pull/127)) +* 🚀 Pressing a key now shows the controls in `UIController`, which helps on tablets with a keyboard. Media keys (play/pause, fast-forward and rewind) control playback, even while the controls are hidden. ([#122](https://github.com/THEOplayer/android-ui/pull/122)) ## [1.14.2] (2026-10-13) From 1e9140ec9ac7c27b6a0a78dd6bccbb34b6b0d042 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 14:40:57 +0000 Subject: [PATCH 4/5] Don't hide keys from open menus Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/theoplayer/android/ui/UIController.kt | 20 +-- .../android/ui/UIControllerKeysTest.kt | 120 ++++++++++++++++++ 2 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 030aeae..575b3ce 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -256,7 +256,7 @@ internal fun UIControllerImpl( onDispose { scope.dispose() } } - val uiState by remember(scope, pictureInPictureState, errorState) { + val uiState = remember(scope, pictureInPictureState, errorState) { derivedStateOf { val currentMenu = scope.currentMenu when { @@ -267,11 +267,7 @@ internal fun UIControllerImpl( } } } - val backgroundVisible = when (uiState) { - is UIState.Controls -> controlsVisible.value - is UIState.Hidden -> false - else -> true - } + val backgroundVisible = uiState.value.isVisible(controlsVisible.value) val background by animateColorAsState( label = "BackgroundAnimation", targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), @@ -308,7 +304,7 @@ internal fun UIControllerImpl( ) .handlePlayerKeys( player = nativePlayer, - controlsVisible = { controlsVisible.value }, + controlsVisible = { uiState.value.isVisible(controlsVisible.value) }, showControls = { forceControlsHidden = false tapCount++ @@ -325,7 +321,7 @@ internal fun UIControllerImpl( modifier = Modifier .fillMaxSize() .background(background), - targetState = uiState, + targetState = uiState.value, transitionSpec = { if (targetState is UIState.Error) { // Show errors immediately @@ -384,7 +380,13 @@ private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() object Controls : UIState() - object Hidden + object Hidden : UIState() +} + +private fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { + is UIState.Controls -> controlsVisible + is UIState.Hidden -> false + else -> true } @Composable diff --git a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt new file mode 100644 index 0000000..79d482b --- /dev/null +++ b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt @@ -0,0 +1,120 @@ +package com.theoplayer.android.ui + +import android.content.Context +import android.view.KeyEvent +import androidx.activity.ComponentActivity +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.player.Player as NativePlayer +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class UIControllerKeysTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun dpadDownReachesOpenMenuWhenPlayingControlsAreHidden() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + val menuFocusRequester = FocusRequester() + lateinit var uiControllerScope: UIControllerScope + var menuItemComposed = false + var menuItemFocused = false + var menuItemSawKey = false + + composeRule.setContent { + UIController( + theoplayerView = theoplayerView, + centerOverlay = { + uiControllerScope = this + Box(Modifier.size(1.dp).testTag("center-overlay")) + }, + bottomChrome = { + Box(Modifier.size(1.dp).testTag("controls-visible")) + } + ) + } + + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertTrue( + composeRule.onAllNodesWithTag("center-overlay") + .fetchSemanticsNodes() + .isNotEmpty() + ) + assertTrue( + composeRule.onAllNodesWithTag("controls-visible") + .fetchSemanticsNodes() + .isEmpty() + ) + composeRule.runOnIdle { + uiControllerScope.openMenu { + Box( + Modifier + .fillMaxSize() + .focusRequester(menuFocusRequester) + .onGloballyPositioned { menuItemComposed = true } + .onFocusChanged { menuItemFocused = it.isFocused } + .onKeyEvent { + menuItemSawKey = true + true + } + .focusable() + .testTag("menu-item") + ) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("center-overlay").fetchSemanticsNodes().isEmpty() + } + composeRule.waitUntil(10_000) { menuItemComposed } + composeRule.runOnIdle { menuFocusRequester.requestFocus() } + composeRule.waitUntil(10_000) { menuItemFocused } + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN) + ) + ) + assertTrue(menuItemSawKey) + } + + private fun mockPlayingPlayer(): NativePlayer { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + every { player.isPaused } returns false + every { player.isEnded } returns false + every { player.ads } returns ads + return player + } + + private class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: NativePlayer + ) : THEOplayerView(context) { + override fun getPlayer(): NativePlayer = nativePlayer + } +} From ab6fa9b50114b8d78e261abfee1dfce9698cb517 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:08:21 +0000 Subject: [PATCH 5/5] Use delegated uiState in UIController Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../main/java/com/theoplayer/android/ui/UIController.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 575b3ce..61806fd 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -256,7 +256,7 @@ internal fun UIControllerImpl( onDispose { scope.dispose() } } - val uiState = remember(scope, pictureInPictureState, errorState) { + val uiState by remember(scope, pictureInPictureState, errorState) { derivedStateOf { val currentMenu = scope.currentMenu when { @@ -267,7 +267,7 @@ internal fun UIControllerImpl( } } } - val backgroundVisible = uiState.value.isVisible(controlsVisible.value) + val backgroundVisible = uiState.isVisible(controlsVisible.value) val background by animateColorAsState( label = "BackgroundAnimation", targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), @@ -304,7 +304,7 @@ internal fun UIControllerImpl( ) .handlePlayerKeys( player = nativePlayer, - controlsVisible = { uiState.value.isVisible(controlsVisible.value) }, + controlsVisible = { uiState.isVisible(controlsVisible.value) }, showControls = { forceControlsHidden = false tapCount++ @@ -321,7 +321,7 @@ internal fun UIControllerImpl( modifier = Modifier .fillMaxSize() .background(background), - targetState = uiState.value, + targetState = uiState, transitionSpec = { if (targetState is UIState.Error) { // Show errors immediately @@ -383,6 +383,7 @@ private sealed class UIState { object Hidden : UIState() } +/** Returns whether this UI state is visible. */ private fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { is UIState.Controls -> controlsVisible is UIState.Hidden -> false