From 69f6d6a66120b55814572098c174c0f96e847dbb Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:49:46 +0000 Subject: [PATCH 1/3] Share UIController core between mobile and TV Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 1 + ui-common/build.gradle.kts | 1 + .../android/ui/ControlsVisibility.kt | 79 +++++ .../theoplayer/android/ui/UIControllerCore.kt | 233 ++++++++++++++ .../android/ui/UIControllerState.kt | 75 +++++ .../android/ui/theme/PlayerAnimations.kt | 37 +++ .../com/theoplayer/android/ui/UIController.kt | 296 +++--------------- .../com/theoplayer/android/ui/theme/Theme.kt | 24 -- .../android/ui/UIControllerKeysTest.kt | 68 ++++ 9 files changed, 543 insertions(+), 271 deletions(-) create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt create mode 100644 ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c6f2d45..b5be91c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ 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" } diff --git a/ui-common/build.gradle.kts b/ui-common/build.gradle.kts index 0104fa31..95b5c990 100644 --- a/ui-common/build.gradle.kts +++ b/ui-common/build.gradle.kts @@ -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) diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt b/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt new file mode 100644 index 00000000..abec7ca6 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/ControlsVisibility.kt @@ -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, + 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++ + } + ) +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt new file mode 100644 index 00000000..f3fc663c --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerCore.kt @@ -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, + errorState: ErrorState, + controls: ControlsVisibility, + color: Color, + contentTransform: AnimatedContentTransitionScope.() -> 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.() -> 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 { null } diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt new file mode 100644 index 00000000..4e821ca2 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/UIControllerState.kt @@ -0,0 +1,75 @@ +package com.theoplayer.android.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.ui.util.rememberIsInPipMode + +/** + * The active content state for a player UI. + */ +@InternalUiApi +sealed class UIState { + data object Error : UIState() + data class Menu(val menu: MenuContent) : UIState() + data object Controls : UIState() + data object Hidden : UIState() +} + +/** + * Returns whether this state should be treated as visible. + */ +@InternalUiApi +fun UIState.isVisible(controlsVisible: Boolean): Boolean = when (this) { + is UIState.Controls -> controlsVisible + is UIState.Hidden -> false + else -> true +} + +/** + * Remembers the UI controller scope and disposes it with the composition. + */ +@InternalUiApi +@Composable +fun rememberUIControllerScope( + theoplayerView: THEOplayerView?, + player: Player? +): UIControllerScopeImpl { + val scope = remember(theoplayerView, player) { + UIControllerScopeImpl(theoplayerView, player) + } + if (player == null) { + val isInPipMode = rememberIsInPipMode() + SideEffect { scope.activityInPipMode = isInPipMode } + } + DisposableEffect(scope) { + onDispose { scope.dispose() } + } + return scope +} + +/** + * Derives the active UI state from menus, errors, and picture-in-picture mode. + */ +@InternalUiApi +@Composable +fun rememberUIState( + scope: UIControllerScopeImpl, + errorState: ErrorState, + pictureInPictureState: PictureInPictureState? +): State = remember(scope, errorState, pictureInPictureState) { + derivedStateOf { + val currentMenu = scope.currentMenu + when { + pictureInPictureState?.pictureInPicture == true -> UIState.Hidden + errorState.error != null -> UIState.Error + currentMenu != null -> UIState.Menu(currentMenu) + else -> UIState.Controls + } + } +} diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt new file mode 100644 index 00000000..2e32b673 --- /dev/null +++ b/ui-common/src/main/java/com/theoplayer/android/ui/theme/PlayerAnimations.kt @@ -0,0 +1,37 @@ +package com.theoplayer.android.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import com.theoplayer.android.ui.InternalUiApi +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Animation settings used by THEOplayer UI components. + */ +@Immutable +data class PlayerAnimations( + /** + * The animation duration when fading in the player controls. + */ + val controlsEnterDuration: Duration = 0.milliseconds, + /** + * The animation duration when fading out the player controls. + */ + val controlsExitDuration: Duration = 500.milliseconds, + /** + * The delay for a loading spinner to become visible after the player starts buffering. + */ + val loadingSpinnerDelay: Duration = 500.milliseconds, + /** + * How long the controls stay visible after user interaction while playing. + */ + val controlsHideDelay: Duration = 2.seconds +) + +/** + * Animation settings shared by player UI modules. + */ +@InternalUiApi +val LocalPlayerAnimations = staticCompositionLocalOf { PlayerAnimations() } diff --git a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 61806fdb..1a54559b 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -1,13 +1,10 @@ package com.theoplayer.android.ui import android.app.Activity -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedContentTransitionScope +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.animation.slideInHorizontally @@ -15,25 +12,10 @@ import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -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.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -44,10 +26,6 @@ import com.theoplayer.android.api.cast.chromecast.PlayerCastState import com.theoplayer.android.api.source.SourceDescription import com.theoplayer.android.ui.theme.THEOplayerTheme import com.theoplayer.android.ui.util.rememberIsInPipMode -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.DurationUnit /** * A container component for a THEOplayer UI. @@ -210,237 +188,61 @@ internal fun UIControllerImpl( val playButtonState = rememberPlayButtonState(nativePlayer) val pictureInPictureState = rememberPictureInPictureState(theoplayerView) val errorState = rememberErrorState(nativePlayer, player?.error) - val adState = rememberAdState(nativePlayer) val videoSizeState = rememberVideoSizeState(nativePlayer) - - var tapCount by remember { mutableIntStateOf(0) } - var isRecentlyTapped by remember { mutableStateOf(false) } - LaunchedEffect(tapCount) { - if (tapCount > 0) { - isRecentlyTapped = true - delay(2.seconds) - isRecentlyTapped = false - } - } - var forceControlsHidden by remember { mutableStateOf(false) } - - // Wait a little bit before showing the controls and enabling animations, - // so we can hide the UI immediately in case of autoplay. - val inspectionMode = LocalInspectionMode.current - var isReady by remember { mutableStateOf(inspectionMode) } - LaunchedEffect(Unit) { - delay(50.milliseconds) - isReady = true - } - - val controlsVisible = remember(firstPlayState, castState, playButtonState) { - derivedStateOf { - if (!isReady) { - false - } else if (!firstPlayState.firstPlay || castState.castState == PlayerCastState.CONNECTED) { - true - } else if (forceControlsHidden) { - false - } else { - isRecentlyTapped || playButtonState.paused - } - } - } - - val scope = remember(theoplayerView, player) { UIControllerScopeImpl(theoplayerView, player) } - if (player == null) { - val isInPipMode = rememberIsInPipMode() - SideEffect { scope.activityInPipMode = isInPipMode } - } - DisposableEffect(scope) { - onDispose { scope.dispose() } - } - - val uiState by remember(scope, pictureInPictureState, errorState) { - derivedStateOf { - val currentMenu = scope.currentMenu - when { - pictureInPictureState.pictureInPicture -> UIState.Hidden - errorState.error != null -> UIState.Error - currentMenu != null -> UIState.Menu(currentMenu) - else -> UIState.Controls - } - } - } - val backgroundVisible = uiState.isVisible(controlsVisible.value) - val background by animateColorAsState( - label = "BackgroundAnimation", - targetValue = if (backgroundVisible) color else color.copy(alpha = 0f), - animationSpec = tween( - easing = LinearEasing, - durationMillis = if (backgroundVisible) { - THEOplayerTheme.playerAnimations.controlsEnterDuration.toInt( - DurationUnit.MILLISECONDS - ) - } else { - THEOplayerTheme.playerAnimations.controlsExitDuration.toInt( - DurationUnit.MILLISECONDS - ) - } - ) + val scope = rememberUIControllerScope(theoplayerView, player) + val uiState = rememberUIState(scope, errorState, pictureInPictureState) + val controls = rememberControlsVisibility( + firstPlay = firstPlayState.firstPlay, + paused = playButtonState.paused, + forceVisible = castState.castState == PlayerCastState.CONNECTED, + hideDelay = THEOplayerTheme.playerAnimations.controlsHideDelay ) - PlayerContainer( - theoplayerView = theoplayerView, - modifier = Modifier - .background(Color.Black) - .then(modifier) + UIControllerCore( + modifier = modifier .playerAspectRatio(videoSizeState) .toggleControlsOnTap( - controlsVisible = controlsVisible, - showControlsTemporarily = { - forceControlsHidden = false - tapCount++ - }, - hideControls = { - forceControlsHidden = true - tapCount++ - } - ) - .handlePlayerKeys( - player = nativePlayer, - controlsVisible = { uiState.isVisible(controlsVisible.value) }, - showControls = { - forceControlsHidden = false - tapCount++ - } - ) - ) { - CompositionLocalProvider( - LocalPlayer provides player, - LocalTHEOplayerView provides theoplayerView, - LocalUIControllerErrorState provides errorState - ) { - AnimatedContent( - label = "ContentAnimation", - modifier = Modifier - .fillMaxSize() - .background(background), - targetState = uiState, - transitionSpec = { - if (targetState is UIState.Error) { - // Show errors immediately - EnterTransition.None togetherWith ExitTransition.None - } else if (initialState is UIState.Menu && targetState is UIState.Menu) { - if (scope.lastWasClosed) { - // Close menu towards the right - slideInHorizontally { -it } togetherWith - slideOutHorizontally { it } - } else { - // Open new menu towards the left - slideInHorizontally(initialOffsetX = { it }) togetherWith - slideOutHorizontally(targetOffsetX = { -it }) - } - } else if (targetState is UIState.Menu) { - // Open first menu from the bottom - slideInVertically { it / 4 } + fadeIn() togetherWith fadeOut() - } else if (initialState is UIState.Menu) { - // Close last menu towards the bottom - fadeIn() togetherWith slideOutVertically { it / 4 } + fadeOut() - } else { - EnterTransition.None togetherWith ExitTransition.None - } - } - ) { uiState -> - when (uiState) { - is UIState.Error -> { - errorOverlay?.let { scope.it() } - } - - is UIState.Menu -> { - uiState.menu.let { scope.it() } - } - - is UIState.Controls -> { - scope.PlayerControls( - controlsVisible = controlsVisible.value, - animationsActive = isReady && !adState.playingAd, - centerOverlay = centerOverlay, - topChrome = topChrome, - centerChrome = centerChrome, - bottomChrome = bottomChrome - ) - } - - is UIState.Hidden -> {} - } - } - } - } -} - -internal val LocalUIControllerErrorState = staticCompositionLocalOf { null } - -private sealed class UIState { - object Error : UIState() - data class Menu(val menu: MenuContent) : UIState() - object Controls : UIState() - 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 + controlsVisible = controls.visible, + showControlsTemporarily = controls.show, + hideControls = controls.hide + ), + theoplayerView = theoplayerView, + player = player, + scope = scope, + uiState = uiState, + errorState = errorState, + controls = controls, + color = color, + contentTransform = mobileContentTransform(scope), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) } -@Composable -private fun UIControllerScope.PlayerControls( - controlsVisible: Boolean, - animationsActive: Boolean, - centerOverlay: (@Composable UIControllerScope.() -> Unit)? = null, - topChrome: (@Composable UIControllerScope.() -> Unit)? = null, - centerChrome: (@Composable UIControllerScope.() -> Unit)? = null, - bottomChrome: (@Composable UIControllerScope.() -> Unit)? = null -) { - 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 = THEOplayerTheme.playerAnimations.controlsEnterDuration.toInt( - DurationUnit.MILLISECONDS - ) - ) - ) else EnterTransition.None, - exit = if (animationsActive) fadeOut( - animationSpec = tween( - easing = LinearEasing, - durationMillis = THEOplayerTheme.playerAnimations.controlsExitDuration.toInt( - DurationUnit.MILLISECONDS - ) - ) - ) else ExitTransition.None, - ) { - 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() } +/** + * Returns the mobile menu transitions. + */ +private fun mobileContentTransform( + scope: UIControllerScopeImpl +): AnimatedContentTransitionScope.() -> ContentTransform = { + if (targetState is UIState.Error) { + EnterTransition.None togetherWith ExitTransition.None + } else if (initialState is UIState.Menu && targetState is UIState.Menu) { + if (scope.lastWasClosed) { + slideInHorizontally { -it } togetherWith slideOutHorizontally { it } + } else { + slideInHorizontally(initialOffsetX = { it }) togetherWith + slideOutHorizontally(targetOffsetX = { -it }) } + } else if (targetState is UIState.Menu) { + slideInVertically { it / 4 } + fadeIn() togetherWith fadeOut() + } else if (initialState is UIState.Menu) { + fadeIn() togetherWith slideOutVertically { it / 4 } + fadeOut() + } else { + EnterTransition.None togetherWith ExitTransition.None } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt index 7bd5372e..94d0e668 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/theme/Theme.kt @@ -13,9 +13,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import com.theoplayer.android.ui.LiveButton -import com.theoplayer.android.ui.LoadingSpinner -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -99,27 +96,6 @@ data class PlayerColors( private val LocalPlayerColors = staticCompositionLocalOf { PlayerColors() } -/** - * Animation settings used by THEOplayer UI components. - */ -@Immutable -data class PlayerAnimations( - /** - * The animation duration when fading in the player controls. - */ - val controlsEnterDuration: Duration = 0.milliseconds, - /** - * The animation duration when fading out the player controls. - */ - val controlsExitDuration: Duration = 500.milliseconds, - /** - * The delay for the [LoadingSpinner] to become visible after the player starts buffering. - */ - val loadingSpinnerDelay: Duration = 500.milliseconds, -) - -private val LocalPlayerAnimations = staticCompositionLocalOf { PlayerAnimations() } - /** * Provides theme colors, shapes and fonts to be used by the player. * diff --git a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt index 79d482b7..9faffeab 100644 --- a/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt +++ b/ui/src/test/java/com/theoplayer/android/ui/UIControllerKeysTest.kt @@ -3,6 +3,7 @@ package com.theoplayer.android.ui import android.content.Context import android.view.KeyEvent import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -22,6 +23,7 @@ import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.player.Player as NativePlayer import io.mockk.every import io.mockk.mockk +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -101,6 +103,72 @@ class UIControllerKeysTest { assertTrue(menuItemSawKey) } + @Test + fun backClosesMenuBeforeFallingThroughToActivity() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + lateinit var uiControllerScope: UIControllerScope + + composeRule.setContent { + UIController( + theoplayerView = theoplayerView, + centerOverlay = { uiControllerScope = this } + ) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + uiControllerScope.openMenu { + Box(Modifier.size(20.dp).testTag("menu")) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("menu").fetchSemanticsNodes().isNotEmpty() + } + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTrue(composeRule.onAllNodesWithTag("menu").fetchSemanticsNodes().isEmpty()) + assertEquals(0, activityBackCalls) + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + assertEquals(1, activityBackCalls) + } + + @Test + fun backFallsThroughWhenNoMenuIsOpen() { + val nativePlayer = mockPlayingPlayer() + val theoplayerView = PlayerBackedTHEOplayerView(composeRule.activity, nativePlayer) + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + composeRule.setContent { + UIController(theoplayerView = theoplayerView) + } + composeRule.waitForIdle() + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + private fun mockPlayingPlayer(): NativePlayer { val ads = mockk(relaxed = true) every { ads.isPlaying } returns false From f1f8a8015f767584e4c0d6dfd5355fdddb2e3be2 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:50:25 +0000 Subject: [PATCH 2/3] Add Android TV UI module with UIController Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + .../com/theoplayer/android/ui/PlayerKeys.kt | 16 +- ui-tv/build.gradle.kts | 129 ++++ ui-tv/proguard-rules.pro | 0 .../android/ui/tv/ExperimentalTvUiApi.kt | 17 + .../theoplayer/android/ui/tv/UIController.kt | 201 +++++++ .../theoplayer/android/ui/tv/theme/Theme.kt | 34 ++ .../android/ui/tv/UIControllerTest.kt | 557 ++++++++++++++++++ 9 files changed, 950 insertions(+), 7 deletions(-) create mode 100644 ui-tv/build.gradle.kts create mode 100644 ui-tv/proguard-rules.pro create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt create mode 100644 ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt create mode 100644 ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b5be91c5..2030670d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ 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" } @@ -49,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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9887aa76..77607b5a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,3 +20,4 @@ rootProject.name = "THEOplayer Open Video UI for Android" include(":app") include(":ui") include(":ui-common") +include(":ui-tv") diff --git a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt index 5796cf9e..9b708015 100644 --- a/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt +++ b/ui-common/src/main/java/com/theoplayer/android/ui/PlayerKeys.kt @@ -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() @@ -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()) { diff --git a/ui-tv/build.gradle.kts b/ui-tv/build.gradle.kts new file mode 100644 index 00000000..cf65d3ad --- /dev/null +++ b/ui-tv/build.gradle.kts @@ -0,0 +1,129 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion + +buildscript { + dependencies { + classpath(libs.dokka.base) + } +} + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.dokka) + alias(libs.plugins.dokka.javadoc) + id("android-ui.library-conventions") +} + +android { + namespace = "com.theoplayer.android.ui.tv" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + buildFeatures { + compose = true + } + testOptions { + unitTests.isIncludeAndroidResources = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +kotlin { + compilerOptions { + apiVersion = KotlinVersion.KOTLIN_2_0 + jvmTarget = JvmTarget.JVM_1_8 + optIn.add("com.theoplayer.android.ui.InternalUiApi") + optIn.add("com.theoplayer.android.ui.ExperimentalPlayerStateApi") + optIn.add("com.theoplayer.android.ui.tv.ExperimentalTvUiApi") + } +} + +dependencies { + api(project(":ui-common")) + + implementation(platform(libs.androidx.compose.bom)) + + implementation(libs.androidx.tv.material) + implementation(libs.androidx.compose.animation) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.ui.ui) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.ui.toolingPreview) + + testImplementation(libs.junit4) + testImplementation(libs.mockk) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.testJunit4) + testImplementation(libs.theoplayer) + + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.testManifest) + + dokkaPlugin(libs.dokka.plugin) +} + +dokka { + moduleName = "android-ui-tv" + + dokkaSourceSets.configureEach { + sourceLink { + localDirectory = file("src/main/java") + remoteUrl("https://github.com/THEOplayer/android-ui/blob/${version}/ui-tv/src/main/java") + remoteLineSuffix = "#L" + } + + externalDocumentationLinks { + register("com.theoplayer.android.api") { + url("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/") + packageListUrl("https://optiview.dolby.com/docs/theoplayer/v11/api-reference/android/package-list") + } + } + } +} + +val dokkaJavadocJar = tasks.register("dokkaJavadocJar") { + group = "dokka" + from(tasks.dokkaGeneratePublicationJavadoc) + dependsOn(tasks.dokkaGeneratePublicationJavadoc) + archiveClassifier.set("javadoc") +} + +publishing { + publications { + register("release") { + groupId = "com.theoplayer.android-ui" + artifactId = "android-ui-tv" + version = project.version as String + artifact(dokkaJavadocJar) + afterEvaluate { + from(components["release"]) + } + } + } +} diff --git a/ui-tv/proguard-rules.pro b/ui-tv/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt new file mode 100644 index 00000000..2504a044 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/ExperimentalTvUiApi.kt @@ -0,0 +1,17 @@ +package com.theoplayer.android.ui.tv + +/** + * Marks declarations that are part of the experimental THEOplayer Android TV UI API. + */ +@RequiresOptIn( + message = "This TV UI API is experimental and may change in future releases.", + level = RequiresOptIn.Level.WARNING +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR +) +annotation class ExperimentalTvUiApi diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt new file mode 100644 index 00000000..10c0e71e --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/UIController.kt @@ -0,0 +1,201 @@ +package com.theoplayer.android.ui.tv + +import android.view.KeyEvent as NativeKeyEvent +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.focusGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.graphics.Color +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.THEOplayerView +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.ui.ControlsVisibility +import com.theoplayer.android.ui.ExperimentalPlayerStateApi +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.UIControllerCore +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.UIState +import com.theoplayer.android.ui.isVisible +import com.theoplayer.android.ui.rememberControlsVisibility +import com.theoplayer.android.ui.rememberErrorState +import com.theoplayer.android.ui.rememberFirstPlayState +import com.theoplayer.android.ui.rememberPlayButtonState +import com.theoplayer.android.ui.rememberUIControllerScope +import com.theoplayer.android.ui.rememberUIState +import com.theoplayer.android.ui.seekPlayerBy +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.togglePlayerPlayPause + +/** + * A TV-oriented UI controller for a THEOplayer view. + * + * @param modifier the [Modifier] to be applied to this controller + * @param theoplayerView the THEOplayer view to host + * @param color the background color behind visible controls + * @param centerOverlay content displayed over the center of the player + * @param errorOverlay content displayed when the player encounters a fatal error + * @param topChrome controls displayed at the top of the player + * @param centerChrome controls displayed at the center of the player + * @param bottomChrome controls displayed at the bottom of the player + */ +@ExperimentalTvUiApi +@OptIn(ExperimentalPlayerStateApi::class, InternalUiApi::class) +@Composable +fun UIController( + modifier: Modifier = Modifier, + theoplayerView: THEOplayerView, + color: Color = Color.Black.copy(alpha = 0.5f), + 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 view = theoplayerView.takeUnless { it.isInEditMode } + val nativePlayer = view?.player + val firstPlayState = rememberFirstPlayState(nativePlayer) + val playButtonState = rememberPlayButtonState(nativePlayer) + val errorState = rememberErrorState(nativePlayer) + val scope = rememberUIControllerScope(view, player = null) + val uiState = rememberUIState( + scope = scope, + errorState = errorState, + pictureInPictureState = null + ) + val playerAnimations = LocalPlayerAnimations.current + val controls = rememberControlsVisibility( + firstPlay = firstPlayState.firstPlay, + paused = playButtonState.paused, + forceVisible = false, + hideDelay = playerAnimations.controlsHideDelay + ) + val focusRequester = remember { FocusRequester() } + val currentUiState = uiState.value + + rememberControlsFocus( + uiState = currentUiState, + menuOpen = scope.currentMenu != null, + controls = controls, + focusRequester = focusRequester + ) + BackHandler( + enabled = currentUiState is UIState.Controls && + controls.visible.value && firstPlayState.firstPlay + ) { + controls.hide() + } + + UIControllerCore( + modifier = modifier.tvHiddenControlsKeys( + player = nativePlayer, + controlsVisible = { uiState.value.isVisible(controls.visible.value) }, + showControls = controls.show + ), + theoplayerView = view, + player = null, + scope = scope, + uiState = uiState, + errorState = errorState, + controls = controls, + color = color, + contentTransform = tvContentTransform(), + controlsModifier = Modifier + .focusRequester(focusRequester) + .focusRestorer() + .focusGroup(), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) +} + +/** + * Restores focus to controls when they become visible or a menu closes. + */ +@OptIn(InternalUiApi::class) +@Composable +private fun rememberControlsFocus( + uiState: UIState, + menuOpen: Boolean, + controls: ControlsVisibility, + focusRequester: FocusRequester +) { + var menuWasOpen by remember { mutableStateOf(false) } + LaunchedEffect(controls.visible.value, menuOpen, uiState) { + if (uiState is UIState.Controls && !menuOpen && + (controls.visible.value || menuWasOpen) + ) { + focusRequester.requestFocus() + } + menuWasOpen = menuOpen + } +} + +/** + * Returns fade-only transitions for TV content. + */ +private fun tvContentTransform(): AnimatedContentTransitionScope.() -> ContentTransform = { + when { + targetState is UIState.Error -> + EnterTransition.None togetherWith ExitTransition.None + targetState is UIState.Menu || initialState is UIState.Menu -> + fadeIn() togetherWith fadeOut() + else -> + EnterTransition.None togetherWith ExitTransition.None + } +} + +/** + * Handles TV transport keys while controls are hidden. + */ +@OptIn(InternalUiApi::class) +private fun Modifier.tvHiddenControlsKeys( + player: Player?, + controlsVisible: () -> Boolean, + showControls: () -> Unit +): Modifier = onPreviewKeyEvent { event -> + if (controlsVisible() || event.type != KeyEventType.KeyDown) { + return@onPreviewKeyEvent false + } + + when (event.nativeKeyEvent.keyCode) { + NativeKeyEvent.KEYCODE_DPAD_CENTER, + NativeKeyEvent.KEYCODE_ENTER -> { + showControls() + togglePlayerPlayPause(player) + true + } + NativeKeyEvent.KEYCODE_DPAD_LEFT -> { + showControls() + seekPlayerBy(player, -SEEK_OFFSET_SECONDS) + true + } + NativeKeyEvent.KEYCODE_DPAD_RIGHT -> { + showControls() + seekPlayerBy(player, SEEK_OFFSET_SECONDS) + true + } + else -> false + } +} + +private const val SEEK_OFFSET_SECONDS = 10 diff --git a/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt new file mode 100644 index 00000000..347fe822 --- /dev/null +++ b/ui-tv/src/main/java/com/theoplayer/android/ui/tv/theme/Theme.kt @@ -0,0 +1,34 @@ +package com.theoplayer.android.ui.tv.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.tv.material3.ColorScheme +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Typography +import androidx.tv.material3.darkColorScheme +import com.theoplayer.android.ui.tv.ExperimentalTvUiApi +import com.theoplayer.android.ui.theme.LocalPlayerAnimations +import com.theoplayer.android.ui.theme.PlayerAnimations +import kotlin.time.Duration.Companion.seconds + +/** + * Provides TV Material theme values and animations to a player UI. + * + * @param playerAnimations animation settings for the TV player UI + */ +@ExperimentalTvUiApi +@Composable +fun THEOplayerTvTheme( + colorScheme: ColorScheme = darkColorScheme(), + typography: Typography = MaterialTheme.typography, + playerAnimations: PlayerAnimations = PlayerAnimations(controlsHideDelay = 5.seconds), + content: @Composable () -> Unit +) { + CompositionLocalProvider(LocalPlayerAnimations provides playerAnimations) { + MaterialTheme( + colorScheme = colorScheme, + typography = typography, + content = content + ) + } +} diff --git a/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt new file mode 100644 index 00000000..d8269f3f --- /dev/null +++ b/ui-tv/src/test/java/com/theoplayer/android/ui/tv/UIControllerTest.kt @@ -0,0 +1,557 @@ +package com.theoplayer.android.ui.tv + +import android.content.Context +import android.view.KeyEvent +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads +import com.theoplayer.android.api.error.THEOplayerException +import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.player.ErrorEvent +import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.player.Player +import com.theoplayer.android.api.timerange.TimeRanges +import com.theoplayer.android.ui.InternalUiApi +import com.theoplayer.android.ui.UIControllerScope +import com.theoplayer.android.ui.tv.theme.THEOplayerTvTheme +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +@OptIn(ExperimentalTvUiApi::class, InternalUiApi::class) +class UIControllerTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun hiddenDpadDownShowsControlsAndConsumesKey() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + var childSawKey = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester) { + childSawKey = true + false + }, + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + + assertTagExists("controls") + assertFalse(childSawKey) + } + + @Test + fun hiddenDpadLeftAndRightSeekByTenSeconds() { + val player = mockPlayingPlayer(currentTime = 40.0) + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester) + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + verify(exactly = 1) { player.currentTime = 50.0 } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_LEFT)) + verify(exactly = 1) { player.currentTime = 30.0 } + } + + @Test + fun hiddenDpadCenterPausesAndShowsControls() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER)) + + verify(exactly = 1) { player.pause() } + waitForTag("controls") + assertTagExists("controls") + } + + @Test + fun visibleDpadRightReachesFocusedControlWithoutSeeking() { + val player = mockPlayingPlayer(currentTime = 40.0) + val keyFocusRequester = FocusRequester() + var focusedControlSawKey = false + var focused = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box( + Modifier + .size(20.dp) + .onFocusChanged { focused = it.isFocused } + .onKeyEvent { + focusedControlSawKey = true + true + } + .focusable() + .testTag("control") + ) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitUntil(10_000) { focused } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + + assertTrue(focusedControlSawKey) + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun controlsAutoHideAfterFiveSecondsWhilePlaying() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + assertTagExists("controls") + + composeRule.mainClock.advanceTimeBy(CONTROLS_AUTO_HIDE_DURATION_MILLIS) + composeRule.waitForIdle() + + assertTagDoesNotExist("controls") + } + + @Test + fun backClosesMenuBeforeFallingThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box( + Modifier + .size(20.dp) + .clickable { + openMenu { + Box(Modifier.fillMaxSize().testTag("menu")) + } + } + .focusable() + .testTag("open-menu") + ) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("open-menu") + composeRule.onNodeWithTag("open-menu").performClick() + waitForTag("menu") + assertTagExists("menu") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTagDoesNotExist("menu") + assertEquals(0, activityBackCalls) + waitForTag("open-menu") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + assertTagDoesNotExist("open-menu") + assertEquals(0, activityBackCalls) + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun backHidesVisibleControlsAndFallsThroughWhenHidden() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + waitForTag("controls") + assertTagExists("controls") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + composeRule.waitForIdle() + + assertTagDoesNotExist("controls") + assertEquals(0, activityBackCalls) + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun dpadCenterReachesMenuWhenControlsAreHidden() { + val player = mockPlayingPlayer() + var menuItemSawKey = false + openMenuWhileControlsAreHidden(player) { + menuItemSawKey = true + true + } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_CENTER)) + + assertTrue(menuItemSawKey) + verify(exactly = 0) { player.pause() } + verify(exactly = 0) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun dpadRightReachesMenuWhenControlsAreHidden() { + val player = mockPlayingPlayer() + var menuItemSawKey = false + openMenuWhileControlsAreHidden(player) { + menuItemSawKey = true + true + } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_RIGHT)) + + assertTrue(menuItemSawKey) + verify(exactly = 0) { player.pause() } + verify(exactly = 0) { player.play() } + verify(exactly = 0) { player.currentTime = any() } + } + + @Test + fun backBeforeFirstPlayFallsThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + setController( + player = mockNotPlayedPlayer(), + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + waitForTag("controls") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + } + + @Test + fun backOnErrorFallsThroughToActivity() { + var activityBackCalls = 0 + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + activityBackCalls++ + } + } + composeRule.activity.onBackPressedDispatcher.addCallback(callback) + val player = mockNotPlayedPlayer() + val errorListener = slot>() + val errorListeners = mutableListOf>() + every { + player.addEventListener(PlayerEventTypes.ERROR, capture(errorListener)) + } answers { + errorListeners += errorListener.captured + } + setController( + player = player, + errorOverlay = { + Box(Modifier.size(20.dp).testTag("error")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitUntil(10_000) { errorListeners.isNotEmpty() } + composeRule.waitForIdle() + val errorEvent = mockk() + every { errorEvent.errorObject } returns mockk() + composeRule.runOnIdle { + errorListeners.forEach { it.handleEvent(errorEvent) } + } + waitForTag("error") + + composeRule.runOnIdle { + composeRule.activity.onBackPressedDispatcher.onBackPressed() + } + + assertEquals(1, activityBackCalls) + assertTagExists("error") + } + + @Test + fun controlsRequestFocusOnFirstBottomChromeControl() { + val player = mockPlayingPlayer() + val keyFocusRequester = FocusRequester() + var firstControlFocused = false + var secondControlFocused = false + setController( + player = player, + centerOverlay = focusableOverlay(keyFocusRequester), + bottomChrome = { + Row { + Box( + Modifier + .size(20.dp) + .onFocusChanged { firstControlFocused = it.isFocused } + .focusable() + .testTag("first-control") + ) + Box( + Modifier + .size(20.dp) + .onFocusChanged { secondControlFocused = it.isFocused } + .focusable() + .testTag("second-control") + ) + } + } + ) + composeRule.runOnIdle { keyFocusRequester.requestFocus() } + + assertTrue(dispatchKey(KeyEvent.KEYCODE_DPAD_DOWN)) + composeRule.waitUntil(10_000) { firstControlFocused } + + assertTrue(firstControlFocused) + assertFalse(secondControlFocused) + } + + private fun setController( + player: Player, + 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 + ) { + lateinit var theoplayerView: THEOplayerView + composeRule.setContent { + val view = remember { + PlayerBackedTHEOplayerView(composeRule.activity, player) + } + theoplayerView = view + THEOplayerTvTheme { + UIController( + modifier = Modifier.fillMaxSize(), + theoplayerView = view, + color = Color.Black.copy(alpha = 0.5f), + centerOverlay = centerOverlay, + errorOverlay = errorOverlay, + topChrome = topChrome, + centerChrome = centerChrome, + bottomChrome = bottomChrome + ) + } + } + composeRule.waitUntil(10_000) { + val container = theoplayerView.findViewById( + com.theoplayer.android.R.id.theo_ui_container + ) + (0 until container.childCount).any { + container.getChildAt(it) is ComposeView + } + } + } + + private fun openMenuWhileControlsAreHidden( + player: Player, + onMenuKeyEvent: (ComposeKeyEvent) -> Boolean + ) { + val menuFocusRequester = FocusRequester() + lateinit var scope: UIControllerScope + var menuItemFocused = false + setController( + player = player, + centerOverlay = { + scope = this + Box(Modifier.size(1.dp).testTag("center-overlay")) + }, + bottomChrome = { + Box(Modifier.size(20.dp).testTag("controls")) + } + ) + composeRule.mainClock.advanceTimeBy(100) + composeRule.waitForIdle() + assertTagDoesNotExist("controls") + + composeRule.runOnIdle { + scope.openMenu { + Box( + Modifier + .size(20.dp) + .focusRequester(menuFocusRequester) + .onFocusChanged { menuItemFocused = it.isFocused } + .onKeyEvent(onMenuKeyEvent) + .focusable() + .testTag("menu-item") + ) + } + } + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag("center-overlay").fetchSemanticsNodes().isEmpty() + } + waitForTag("menu-item") + composeRule.runOnIdle { menuFocusRequester.requestFocus() } + composeRule.waitUntil(10_000) { menuItemFocused } + } + + private fun waitForTag(tag: String) { + composeRule.waitUntil(10_000) { + composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun assertTagExists(tag: String) { + assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()) + } + + private fun assertTagDoesNotExist(tag: String) { + assertTrue(composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isEmpty()) + } + + private fun focusableOverlay( + focusRequester: FocusRequester, + onKeyEvent: (ComposeKeyEvent) -> Boolean = { false } + ): @Composable UIControllerScope.() -> Unit = { + Box( + Modifier + .fillMaxSize() + .focusRequester(focusRequester) + .onKeyEvent(onKeyEvent) + .focusable() + ) + } + + private fun mockPlayingPlayer(currentTime: Double = 40.0): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + every { player.isPaused } returns false + every { player.isEnded } returns false + every { player.ads } returns ads + every { player.currentTime } returns currentTime + every { player.duration } returns 100.0 + return player + } + + private fun mockNotPlayedPlayer(): Player { + val ads = mockk(relaxed = true) + every { ads.isPlaying } returns false + val player = mockk(relaxed = true) + val played = mockk(relaxed = true) + every { played.length() } returns 0 + every { player.isPaused } returns true + every { player.played } returns played + every { player.ads } returns ads + return player + } + + private class PlayerBackedTHEOplayerView( + context: Context, + private val nativePlayer: Player + ) : THEOplayerView(context) { + override fun getPlayer(): Player = nativePlayer + } + + private fun dispatchKey(keyCode: Int): Boolean = composeRule.activity.dispatchKeyEvent( + KeyEvent(KeyEvent.ACTION_DOWN, keyCode) + ) + + private companion object { + const val CONTROLS_AUTO_HIDE_DURATION_MILLIS = 5_000L + } +} From 28567fb306b104c15328ccb515ca780fd6862ad1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 14 Oct 2026 15:50:39 +0000 Subject: [PATCH 3/3] Update changelog Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b47ffe..5d1fd2d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)