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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
76 changes: 76 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 17 additions & 6 deletions ui/src/main/java/com/theoplayer/android/ui/UIController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -306,6 +302,14 @@ internal fun UIControllerImpl(
tapCount++
}
)
.handlePlayerKeys(
player = nativePlayer,
controlsVisible = { uiState.isVisible(controlsVisible.value) },
showControls = {
forceControlsHidden = false
tapCount++
}
)
) {
CompositionLocalProvider(
LocalPlayer provides player,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading