From 9216b5f3452050771e6ba79f16baaf4cadc61a67 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:07:32 +0000 Subject: [PATCH 1/3] Toggle play/pause with Center on the TV SeekBar Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 + docs/guides/android-tv/custom-ui.md | 2 +- docs/guides/android-tv/getting-started.md | 2 +- .../com/theoplayer/android/ui/tv/SeekBar.kt | 47 ++++++++++++++- .../theoplayer/android/ui/tv/DefaultUITest.kt | 59 +++++++++++++++++++ .../theoplayer/android/ui/tv/SeekBarTest.kt | 48 +++++++++++++++ 6 files changed, 155 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b9c32d9..44ddda1e 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. ([#PR](https://github.com/THEOplayer/android-ui/pull/PR)) + ## [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-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..e113457b 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,18 +2,30 @@ 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.api.player.Player import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.SeekBarState import com.theoplayer.android.ui.rememberSeekBarState +import com.theoplayer.android.ui.togglePlayerPlayPause /** * 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 +52,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 +80,7 @@ fun SeekBar( state.seekTo(value) } }, - modifier = modifier, + modifier = modifier.togglePlayPauseOnCenter(LocalTHEOplayerView.current?.player), enabled = state.enabled, keyStep = seekOffset.toFloat(), keyRepeatStep = (state.valueRange.endInclusive - state.valueRange.start) / 100f, @@ -93,3 +105,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(InternalUiApi::class) +@Composable +private fun Modifier.togglePlayPauseOnCenter(player: Player?): 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 + togglePlayerPlayPause(player) + } + 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, From 7e714ae4a49811b143cac26779bd63aa86b36b85 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:07:54 +0000 Subject: [PATCH 2/3] Add PR link to changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ddda1e..026640fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [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. ([#PR](https://github.com/THEOplayer/android-ui/pull/PR)) +* 💅 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. ([#148](https://github.com/THEOplayer/android-ui/pull/148)) ## [1.15.0] (2026-10-23) From 8ca815682487cada12ae649278518f5cfaecd1b2 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 23 Oct 2026 13:16:54 +0000 Subject: [PATCH 3/3] Add SeekBarState.togglePlayPause() Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../com/theoplayer/android/ui/SeekBarState.kt | 15 +++++++++ .../theoplayer/android/ui/SeekBarStateTest.kt | 32 +++++++++++++++++++ .../com/theoplayer/android/ui/tv/SeekBar.kt | 11 +++---- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 026640fb..3b464321 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ ## [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. ([#148](https://github.com/THEOplayer/android-ui/pull/148)) +* 💅 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) 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 e113457b..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 @@ -13,13 +13,10 @@ 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.api.player.Player import com.theoplayer.android.ui.ExperimentalPlayerStateApi -import com.theoplayer.android.ui.InternalUiApi import com.theoplayer.android.ui.LocalTHEOplayerView import com.theoplayer.android.ui.SeekBarState import com.theoplayer.android.ui.rememberSeekBarState -import com.theoplayer.android.ui.togglePlayerPlayPause /** * A seek bar showing the player's current time and allowing D-pad seeking. @@ -80,7 +77,7 @@ fun SeekBar( state.seekTo(value) } }, - modifier = modifier.togglePlayPauseOnCenter(LocalTHEOplayerView.current?.player), + modifier = modifier.togglePlayPauseOnCenter(state), enabled = state.enabled, keyStep = seekOffset.toFloat(), keyRepeatStep = (state.valueRange.endInclusive - state.valueRange.start) / 100f, @@ -111,9 +108,9 @@ fun SeekBar( * guide. Only key presses that start on this component toggle playback, so the key press that * reveals the controls doesn't toggle twice. */ -@OptIn(InternalUiApi::class) +@OptIn(ExperimentalPlayerStateApi::class) @Composable -private fun Modifier.togglePlayPauseOnCenter(player: Player?): Modifier { +private fun Modifier.togglePlayPauseOnCenter(state: SeekBarState): Modifier { var isKeyPressed by remember { mutableStateOf(false) } return onKeyEvent { event -> if (!event.isCenterKey()) return@onKeyEvent false @@ -121,7 +118,7 @@ private fun Modifier.togglePlayPauseOnCenter(player: Player?): Modifier { KeyEventType.KeyDown -> { if (event.nativeKeyEvent.repeatCount == 0) { isKeyPressed = true - togglePlayerPlayPause(player) + state.togglePlayPause() } true }