Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/android-tv/custom-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/android-tv/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/SeekBarState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 41 additions & 3 deletions ui-tv/src/main/java/com/theoplayer/android/ui/tv/SeekBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
59 changes: 59 additions & 0 deletions ui-tv/src/test/java/com/theoplayer/android/ui/tv/DefaultUITest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<TextTrackStyle>(relaxed = true)
Expand Down
48 changes: 48 additions & 0 deletions ui-tv/src/test/java/com/theoplayer/android/ui/tv/SeekBarTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading