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) 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..5796cf9 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -0,0 +1,76 @@ +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 -> player?.play() + NativeKeyEvent.KEYCODE_MEDIA_PAUSE -> player?.pause() + NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekBy(player, seekOffset) + NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekBy(player, -seekOffset) + } +} + +/** Toggles between play and pause. */ +private fun togglePlayPause(player: Player?) { + if (player == null) return + if (player.isPaused) { + player.play() + } else { + player.pause() + } +} + +/** 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..61806fd 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -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.isVisible(controlsVisible.value) val background by animateColorAsState( label = "BackgroundAnimation", targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), @@ -306,6 +302,14 @@ internal fun UIControllerImpl( tapCount++ } ) + .handlePlayerKeys( + player = nativePlayer, + controlsVisible = { uiState.isVisible(controlsVisible.value) }, + showControls = { + forceControlsHidden = false + tapCount++ + } + ) ) { CompositionLocalProvider( LocalPlayer provides player, @@ -376,7 +380,14 @@ private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() object Controls : UIState() - object Hidden + 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 + else -> true } @Composable 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..3335239 --- /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 mediaPlayPauseCallsPlayWhenPausedAndEnded() { + 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 mediaPlayCallsPlayWhenEnded() { + 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 + } +} 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 + } +}