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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
* 🚀 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))
* 🚀 Pressing Back now closes an open menu in `UIController`. ([#124](https://github.com/THEOplayer/android-ui/pull/124))
* 🚀 Added `PlayerAnimations.controlsHideDelay` to configure how long the controls stay visible after user interaction. ([#124](https://github.com/THEOplayer/android-ui/pull/124))
* 🚀 Added `android-ui-tv`, an experimental UI library for Android TV built on Compose for TV. ([#121](https://github.com/THEOplayer/android-ui/pull/121), [#124](https://github.com/THEOplayer/android-ui/pull/124))

## [1.14.2] (2026-10-13)

Expand Down
3 changes: 3 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ theoplayer-compile = { prefer = "11.+", strictly = "[10.13.0, 12.0)" }
theoplayer-min = { strictly = "7.6.0" }
core = "1.18.0"
core-pip = "1.0.0-alpha02"
tv-material = "1.1.0"

[libraries]
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
androidx-lifecycle-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle-compose" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
Expand All @@ -48,6 +50,7 @@ androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core",
androidx-mediarouter = { group = "androidx.mediarouter", name = "mediarouter", version.ref = "androidx-mediarouter" }
androidx-core = { group = "androidx.core", name = "core", version.ref = "core" }
androidx-core-pip = { group = "androidx.core", name = "core-pip", version.ref = "core-pip" }
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tv-material" }
androidTools-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk-libs" }
playServices-castFramework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "playServices-castFramework" }
gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" }
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ rootProject.name = "THEOplayer Open Video UI for Android"
include(":app")
include(":ui")
include(":ui-common")
include(":ui-tv")
1 change: 1 addition & 0 deletions ui-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ dependencies {
implementation(libs.androidx.ktx)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.animation)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.ui.ui)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.theoplayer.android.ui

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

/**
* Visibility state and callbacks for player controls.
*/
@InternalUiApi
class ControlsVisibility(
val visible: State<Boolean>,
val animationsActive: Boolean,
val show: () -> Unit,
val hide: () -> Unit
)

/**
* Remembers control visibility and interaction state.
*/
@InternalUiApi
@Composable
fun rememberControlsVisibility(
firstPlay: Boolean,
paused: Boolean,
forceVisible: Boolean,
hideDelay: Duration
): ControlsVisibility {
var activityCount by remember { mutableIntStateOf(0) }
var recentlyActive by remember { mutableStateOf(false) }
LaunchedEffect(activityCount, hideDelay) {
if (activityCount > 0) {
recentlyActive = true
delay(hideDelay)
recentlyActive = false
}
}

var forceControlsHidden by remember { mutableStateOf(false) }
val inspectionMode = LocalInspectionMode.current
var isReady by remember { mutableStateOf(inspectionMode) }
LaunchedEffect(Unit) {
delay(50.milliseconds)
isReady = true
}

val visible = remember(firstPlay, paused, forceVisible) {
derivedStateOf {
when {
!isReady -> false
!firstPlay || forceVisible -> true
forceControlsHidden -> false
else -> recentlyActive || paused
}
}
}
return ControlsVisibility(
visible = visible,
animationsActive = isReady,
show = {
forceControlsHidden = false
activityCount++
},
hide = {
forceControlsHidden = true
activityCount++
}
)
}
16 changes: 9 additions & 7 deletions ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,17 @@ private fun Int.isMediaKey(): Boolean = when (this) {
/** 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_PAUSE -> togglePlayerPlayPause(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)
NativeKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> seekPlayerBy(player, seekOffset)
NativeKeyEvent.KEYCODE_MEDIA_REWIND -> seekPlayerBy(player, -seekOffset)
}
}

/** Toggles between play and pause. */
private fun togglePlayPause(player: Player?) {
/** Toggles playback between play and pause. */
@InternalUiApi
fun togglePlayerPlayPause(player: Player?) {
if (player == null) return
if (player.isPaused) {
player.play()
Expand All @@ -66,8 +67,9 @@ private fun togglePlayPause(player: Player?) {
}
}

/** Seeks by [seekOffset], clamped to the media duration. */
private fun seekBy(player: Player?, seekOffset: Int) {
/** Seeks by [seekOffset], skipping ads and clamping to the media duration. */
@InternalUiApi
fun seekPlayerBy(player: Player?, seekOffset: Int) {
if (player == null || player.ads.isPlaying) return
val duration = player.duration
if (!duration.isNaN()) {
Expand Down
233 changes: 233 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package com.theoplayer.android.ui

import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.theoplayer.android.api.THEOplayerView
import com.theoplayer.android.ui.theme.LocalPlayerAnimations
import kotlin.time.DurationUnit

/**
* Renders the shared player UI around the active controls, menu, or error state.
*/
@InternalUiApi
@Composable
fun UIControllerCore(
modifier: Modifier = Modifier,
theoplayerView: THEOplayerView?,
player: Player?,
scope: UIControllerScopeImpl,
uiState: State<UIState>,
errorState: ErrorState,
controls: ControlsVisibility,
color: Color,
contentTransform: AnimatedContentTransitionScope<UIState>.() -> ContentTransform,
controlsModifier: Modifier = Modifier,
centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null,
errorOverlay: (@Composable UIControllerScope.() -> Unit)? = null,
topChrome: (@Composable UIControllerScope.() -> Unit)? = null,
centerChrome: (@Composable UIControllerScope.() -> Unit)? = null,
bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null
) {
val nativePlayer = theoplayerView?.player
val adState = rememberAdState(nativePlayer)
val playerAnimations = LocalPlayerAnimations.current
val backgroundVisible = uiState.value.isVisible(controls.visible.value)
val animatedBackground by animateColorAsState(
label = "BackgroundAnimation",
targetValue = if (backgroundVisible) color else color.copy(alpha = 0f),
animationSpec = tween(
easing = LinearEasing,
durationMillis = if (backgroundVisible) {
playerAnimations.controlsEnterDuration.toInt(DurationUnit.MILLISECONDS)
} else {
playerAnimations.controlsExitDuration.toInt(DurationUnit.MILLISECONDS)
}
)
)

BackHandler(enabled = scope.currentMenu != null) {
scope.closeCurrentMenu()
}

PlayerContainer(
theoplayerView = theoplayerView,
modifier = Modifier
.background(Color.Black)
.then(modifier)
.handlePlayerKeys(
player = nativePlayer,
controlsVisible = {
uiState.value.isVisible(controls.visible.value)
},
showControls = controls.show
)
) {
UIControllerContent(
theoplayerView = theoplayerView,
player = player,
scope = scope,
uiState = uiState.value,
errorState = errorState,
controls = controls,
adPlaying = adState.playingAd,
background = animatedBackground,
controlsModifier = controlsModifier,
contentTransform = contentTransform,
centerOverlay = centerOverlay,
errorOverlay = errorOverlay,
topChrome = topChrome,
centerChrome = centerChrome,
bottomChrome = bottomChrome
)
}
}

/**
* Provides controller locals and displays the active content.
*/
@Composable
private fun UIControllerContent(
theoplayerView: THEOplayerView?,
player: Player?,
scope: UIControllerScopeImpl,
uiState: UIState,
errorState: ErrorState,
controls: ControlsVisibility,
adPlaying: Boolean,
background: Color,
controlsModifier: Modifier,
contentTransform: AnimatedContentTransitionScope<UIState>.() -> ContentTransform,
centerOverlay: (@Composable UIControllerScope.() -> Unit)?,
errorOverlay: (@Composable UIControllerScope.() -> Unit)?,
topChrome: (@Composable UIControllerScope.() -> Unit)?,
centerChrome: (@Composable UIControllerScope.() -> Unit)?,
bottomChrome: (@Composable UIControllerScope.() -> Unit)?
) {
CompositionLocalProvider(
LocalPlayer provides player,
LocalTHEOplayerView provides theoplayerView,
LocalUIControllerErrorState provides errorState
) {
AnimatedContent(
label = "ContentAnimation",
modifier = Modifier
.fillMaxSize()
.background(background),
targetState = uiState,
transitionSpec = contentTransform
) { state ->
when (state) {
UIState.Error -> errorOverlay?.let { scope.it() }
is UIState.Menu -> state.menu.let { scope.it() }
UIState.Controls -> scope.PlayerControls(
controlsVisible = controls.visible.value,
animationsActive = controls.animationsActive && !adPlaying,
controlsModifier = controlsModifier,
centerOverlay = centerOverlay,
topChrome = topChrome,
centerChrome = centerChrome,
bottomChrome = bottomChrome
)
UIState.Hidden -> Unit
}
}
}
}

/**
* Displays the center overlay and animated control slots.
*/
@Composable
private fun UIControllerScope.PlayerControls(
controlsVisible: Boolean,
animationsActive: Boolean,
controlsModifier: Modifier,
centerOverlay: (@Composable UIControllerScope.() -> Unit)?,
topChrome: (@Composable UIControllerScope.() -> Unit)?,
centerChrome: (@Composable UIControllerScope.() -> Unit)?,
bottomChrome: (@Composable UIControllerScope.() -> Unit)?
) {
val playerAnimations = LocalPlayerAnimations.current
Box(modifier = Modifier.fillMaxSize()) {
centerOverlay?.let {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
it()
}
}
AnimatedVisibility(
visible = controlsVisible,
enter = if (animationsActive) fadeIn(
animationSpec = tween(
easing = LinearEasing,
durationMillis = playerAnimations.controlsEnterDuration.toInt(
DurationUnit.MILLISECONDS
)
)
) else EnterTransition.None,
exit = if (animationsActive) fadeOut(
animationSpec = tween(
easing = LinearEasing,
durationMillis = playerAnimations.controlsExitDuration.toInt(
DurationUnit.MILLISECONDS
)
)
) else ExitTransition.None,
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(controlsModifier)
) {
centerChrome?.let {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
it()
}
}
Column(modifier = Modifier.fillMaxSize()) {
topChrome?.let { it() }
Spacer(modifier = Modifier.weight(1f))
bottomChrome?.let { it() }
}
}
}
}
}

/**
* The current error state provided by a UI controller.
*/
@InternalUiApi
val LocalUIControllerErrorState = staticCompositionLocalOf<ErrorState?> { null }
Loading
Loading