diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9c32d9..3b464321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ ## [Unreleased] +* 💅 On Android TV, pressing Center or Enter on the `SeekBar` now toggles play or pause, so viewers don't have to move focus to the play button first. Custom UIs can use the new `SeekBarState.togglePlayPause()` for this. ([#148](https://github.com/THEOplayer/android-ui/pull/148)) + ## [1.15.0] (2026-10-23) * 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. It includes a D-pad friendly `DefaultUI`. Code shared with `android-ui` now lives in a new `android-ui-common` artifact; `android-ui` depends on it, so apps don't need any changes. ([#107](https://github.com/THEOplayer/android-ui/pull/107), [#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124), [#125](https://github.com/THEOplayer/android-ui/pull/125), [#126](https://github.com/THEOplayer/android-ui/pull/126), [#128](https://github.com/THEOplayer/android-ui/pull/128), [#131](https://github.com/THEOplayer/android-ui/pull/131)) diff --git a/docs/guides/android-tv/custom-ui.md b/docs/guides/android-tv/custom-ui.md index 91bcbb85..40d460ff 100644 --- a/docs/guides/android-tv/custom-ui.md +++ b/docs/guides/android-tv/custom-ui.md @@ -62,7 +62,7 @@ fun TvPlayerWithControls() { } ``` -- `SeekBar` lets viewers seek with the D-pad. Holding Left or Right scrubs through the timeline. +- `SeekBar` lets viewers seek with the D-pad. Holding Left or Right scrubs through the timeline, and Center toggles play or pause. - `CurrentTimeDisplay` shows the current time. Set `showRemaining = true` to show the remaining time. - `DurationDisplay` shows the stream duration. - `LiveButton` shows whether the player is at the live point and returns to it when selected. diff --git a/docs/guides/android-tv/getting-started.md b/docs/guides/android-tv/getting-started.md index 91317c45..f9ad0b1c 100644 --- a/docs/guides/android-tv/getting-started.md +++ b/docs/guides/android-tv/getting-started.md @@ -98,7 +98,7 @@ Media transport keys work while the controls are hidden or visible: | Pause | Pause playback | | Fast-forward or Rewind | Seek forward or backward by 10 seconds | -While the controls are visible, the D-pad moves focus between controls. Left and Right on the seek bar seek by 10 seconds. Holding either key scrubs through the timeline and pauses playback while scrubbing. +While the controls are visible, the D-pad moves focus between controls. Left and Right on the seek bar seek by 10 seconds. Holding either key scrubs through the timeline and pauses playback while scrubbing. Center or Enter on the seek bar toggles play or pause. Back closes an open menu first. When the controls are visible, Back hides them. When they are hidden, the player leaves Back for the app's own `BackHandler`. Controls hide after 5 seconds without interaction while playing by default. Set `PlayerAnimations.controlsHideDelay` in `THEOplayerTvTheme` to change this delay. diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt index d7907130..19ca2ed8 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt @@ -118,6 +118,21 @@ class SeekBarState internal constructor(private val player: Player?) { player?.currentTime = seekableRange.start + value.toDouble() } + /** + * Plays or pauses the player. While the user is scrubbing, this instead changes whether + * playback resumes when scrubbing finishes. + */ + fun togglePlayPause() { + val player = player ?: return + if (seekTime != null) { + wasPlayingBeforeSeek = !wasPlayingBeforeSeek + } else if (player.isPaused) { + player.play() + } else { + player.pause() + } + } + /** * Called when the user stops dragging the seek bar. Resumes playback if the player was playing * before the drag started. diff --git a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt index f358fb21..85d03328 100644 --- a/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt +++ b/ui-common/src/test/java/com/theoplayer/android/ui/SeekBarStateTest.kt @@ -112,6 +112,38 @@ class SeekBarStateTest { verify(exactly = 0) { mock.player.play() } } + @Test + fun `togglePlayPause plays and pauses`() = runTest { + val mock = MockPlayer(paused = true) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.togglePlayPause() + verify(exactly = 1) { mock.player.play() } + assertFalse(mock.paused) + + state.togglePlayPause() + verify(exactly = 1) { mock.player.pause() } + assertTrue(mock.paused) + } + + @Test + fun `togglePlayPause while scrubbing changes whether playback resumes`() = runTest { + val mock = MockPlayer(paused = false, currentTime = 10.0) + val state = SeekBarState(mock.player) + launchObserve { state.observe() } + + state.onValueChange(50f) + state.togglePlayPause() + state.onValueChangeFinished() + verify(exactly = 0) { mock.player.play() } + + state.onValueChange(60f) + state.togglePlayPause() + state.onValueChangeFinished() + verify(exactly = 1) { mock.player.play() } + } + @Test fun `disabled during ads and thumb hidden`() = runTest { val mock = MockPlayer(adPlaying = true) diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt index 5ca78755..f627d2d1 100644 --- a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt @@ -2,8 +2,17 @@ package com.theoplayer.android.ui.tv import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import com.theoplayer.android.ui.ExperimentalPlayerStateApi import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.SeekBarState @@ -13,7 +22,7 @@ import com.theoplayer.android.ui.rememberSeekBarState * A seek bar showing the player's current time and allowing D-pad seeking. * * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the - * timeline; playback pauses while scrubbing. + * timeline; playback pauses while scrubbing. Press Center to play or pause. * * @param modifier the [Modifier] to be applied to this seek bar * @param colors [SliderColors] that resolve the seek bar colors for enabled and disabled states. @@ -40,7 +49,7 @@ fun SeekBar( * A seek bar showing the current time from [state] and allowing D-pad seeking. * * Press Left or Right to seek by [seekOffset] seconds. Hold either key to scrub through the - * timeline; playback pauses while scrubbing. + * timeline; playback pauses while scrubbing. Press Center to play or pause. * * @param state the [SeekBarState] to read and control * @param modifier the [Modifier] to be applied to this seek bar @@ -68,7 +77,7 @@ fun SeekBar( state.seekTo(value) } }, - modifier = modifier, + modifier = modifier.togglePlayPauseOnCenter(state), enabled = state.enabled, keyStep = seekOffset.toFloat(), keyRepeatStep = (state.valueRange.endInclusive - state.valueRange.start) / 100f, @@ -93,3 +102,32 @@ fun SeekBar( }, ) } + +/** + * Toggles play/pause when Center or Enter is pressed, following Google's "Playback controls on TV" + * guide. Only key presses that start on this component toggle playback, so the key press that + * reveals the controls doesn't toggle twice. + */ +@OptIn(ExperimentalPlayerStateApi::class) +@Composable +private fun Modifier.togglePlayPauseOnCenter(state: SeekBarState): Modifier { + var isKeyPressed by remember { mutableStateOf(false) } + return onKeyEvent { event -> + if (!event.isCenterKey()) return@onKeyEvent false + when (event.type) { + KeyEventType.KeyDown -> { + if (event.nativeKeyEvent.repeatCount == 0) { + isKeyPressed = true + state.togglePlayPause() + } + true + } + KeyEventType.KeyUp -> isKeyPressed.also { isKeyPressed = false } + else -> false + } + } +} + +/** Checks whether this is a Center or Enter key event. */ +private fun KeyEvent.isCenterKey(): Boolean = + key == Key.DirectionCenter || key == Key.Enter || key == Key.NumPadEnter diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt index 12866373..9a06fd69 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt @@ -75,6 +75,35 @@ class DefaultUITest { composeRule.onNode(seekBar).assertIsFocused() } + @Test + fun centerOnFocusedSeekBarTogglesPlayback() { + val player = mockPlayer(paused = true, played = true) + setDefaultUI(player) + waitUntil { + composeRule + .onAllNodes( + seekBar and SemanticsMatcher.expectValue(SemanticsProperties.Focused, true) + ) + .fetchSemanticsNodes() + .isNotEmpty() + } + + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + composeRule.waitForIdle() + + verify(exactly = 1) { player.play() } + composeRule.onNode(seekBar).assertIsFocused() + } + @Test fun liveStreamShowsLiveButtonWithoutSeekBar() { setDefaultUI(mockPlayer(paused = true, played = true, duration = Double.POSITIVE_INFINITY)) @@ -119,6 +148,36 @@ class DefaultUITest { waitUntil { hasText(TITLE) } } + @Test + fun hiddenDpadCenterPressTogglesPlaybackOnce() { + val player = mockPlayer(paused = false, played = true) + setDefaultUI(player) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertFalse(hasText(TITLE)) + + assertTrue( + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_CENTER) + ) + ) + waitUntil { + composeRule + .onAllNodes( + seekBar and SemanticsMatcher.expectValue(SemanticsProperties.Focused, true) + ) + .fetchSemanticsNodes() + .isNotEmpty() + } + composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DPAD_CENTER) + ) + composeRule.waitForIdle() + + verify(exactly = 1) { player.pause() } + verify(exactly = 0) { player.play() } + } + @Test fun visibleControlsMoveSubtitlesOutOfTheWay() { val textTrackStyle = mockk(relaxed = true) diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt index ac6ff7de..ca0a7cfb 100644 --- a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt @@ -169,6 +169,54 @@ class SeekBarTest { verify(exactly = 0) { player.player.currentTime = any() } } + @Test + fun centerPressTogglesPlayPause() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_UP)) + assertTrue(player.paused) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP)) + assertFalse(player.paused) + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 1) { player.player.play() } + assertEquals(50.0, player.currentTime, 0.0) + } + + @Test + fun heldCenterPressTogglesOnce() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_DOWN, repeat = 0)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_DOWN, repeat = 1)) + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_UP)) + + verify(exactly = 1) { player.player.pause() } + verify(exactly = 0) { player.player.play() } + } + + @Test + fun centerKeyUpWithoutKeyDownIsNotConsumed() { + val player = TestPlayer() + val focusRequester = FocusRequester() + setSeekBar(player, Modifier.focusRequester(focusRequester)) + requestFocus(focusRequester) + + assertFalse(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.ACTION_UP)) + + verify(exactly = 0) { player.player.pause() } + verify(exactly = 0) { player.player.play() } + } + private fun setSeekBar( player: TestPlayer, modifier: Modifier = Modifier,