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 gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ core-pip = "1.0.0-alpha02"
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-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 Down
2 changes: 2 additions & 0 deletions ui-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))

implementation(libs.androidx.ktx)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.ui.ui)

testImplementation(libs.junit4)
Expand Down
38 changes: 38 additions & 0 deletions ui-common/src/main/java/com/theoplayer/android/ui/MenuScope.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.theoplayer.android.ui

import androidx.compose.runtime.Composable

/**
* The content for a new menu, typically this should draw a single [Menu].
*/
typealias MenuContent = @Composable MenuScope.() -> Unit

/**
* Scope for the contents of a [Menu].
*/
interface MenuScope {
/**
* Open a new submenu.
*
* When the submenu is closed, the original menu is shown again.
*
* Only the top-most menu is composed. While the submenu is open, the original menu
* (or the player controls, if no menu was open yet) leaves the composition, and any state
* remembered there is disposed. So instead of passing state from the caller into [menu],
* remember the state inside [menu]:
* ```kotlin
* // Don't: audioTrackState is disposed while the menu is open
* val audioTrackState = rememberAudioTrackState(player)
* openMenu { AudioTrackMenu(audioTrackState) }
*
* // Do
* openMenu { AudioTrackMenu(rememberAudioTrackState(player)) }
* ```
*/
fun openMenu(menu: MenuContent)

/**
* Close the currently open menu.
*/
fun closeCurrentMenu()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.theoplayer.android.ui

import android.view.ViewGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.theoplayer.android.api.THEOplayerView

/**
* Hosts [theoplayerView] and draws [ui] inside its UI container.
*/
@InternalUiApi
@Composable
fun PlayerContainer(
modifier: Modifier = Modifier,
theoplayerView: THEOplayerView?,
ui: @Composable () -> Unit
) {
if (theoplayerView == null) {
Box(
modifier = modifier
) {
ui()
}
} else {
val lifecycle = LocalLifecycleOwner.current.lifecycle
var uiContainer by remember { mutableStateOf<ViewGroup?>(null) }
var composeView by remember { mutableStateOf<ComposeView?>(null) }

AndroidView(
modifier = modifier,
factory = { context ->
uiContainer =
theoplayerView.findViewById(com.theoplayer.android.R.id.theo_ui_container)
// Wrap our UI inside a ComposeView
composeView = ComposeView(context).apply {
// When entering fullscreen, we remove the view from its original location
// and add it to the activity's root view.
// When it is temporarily removed (and detached from the window),
// we do *not* want to lose the composition. Therefore, don't use the default
// DisposeOnDetachedFromWindow or DisposeOnDetachedFromWindowOrReleasedFromPool
// strategies!
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnLifecycleDestroyed(lifecycle)
)
setContent {
ui()
}
}
// Host the THEOplayer view inside our AndroidView
(theoplayerView.parent as? ViewGroup)?.removeView(theoplayerView)
theoplayerView
})

// Install inside THEOplayerView's UI container
DisposableEffect(uiContainer, composeView) {
val container = uiContainer
val view = composeView
if (container != null && view != null) {
container.addView(view)
theoplayerView.postInvalidate()
}
onDispose {
if (container != null && view != null) {
container.removeView(view)
view.disposeComposition()
theoplayerView.postInvalidate()
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.theoplayer.android.ui

import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.theoplayer.android.api.THEOplayerConfig
import com.theoplayer.android.api.THEOplayerView

/**
* Creates and remembers a [THEOplayerView] owned by the composition.
*
* This couples the lifecycle of the view to the current activity: it automatically calls
* [THEOplayerView.onPause] and [THEOplayerView.onResume] whenever the current activity is
* [paused][Activity.onPause] or [resumed][Activity.onResume] (resuming an ad that was
* interrupted), and it destroys the view when it leaves the composition.
*
* In previews, the SDK does not initialize the view ([View.isInEditMode]), so do not
* access its [THEOplayerView.player] there. [UIController] and [DefaultUI] handle this
* automatically.
*
* @param config the player configuration
*/
@Composable
fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView {
val context = LocalContext.current
val inspectionMode = LocalInspectionMode.current
val theoplayerView = remember {
THEOplayerView(if (inspectionMode) PreviewContext(context) else context, config)
}
if (!theoplayerView.isInEditMode) {
setupTHEOplayerView(theoplayerView, destroyOnLifecycleDestroy = false)
DisposableEffect(theoplayerView) {
onDispose {
theoplayerView.onDestroy()
}
}
}
return theoplayerView
}

/**
* A context for creating a [THEOplayerView] in a preview.
*
* The [THEOplayerView] constructor casts the application context to [Application],
* but layoutlib's application context is not an [Application].
*/
private class PreviewContext(base: Context) : ContextWrapper(base) {
private val application = PreviewApplication(base.applicationContext)

override fun getApplicationContext(): Context = application
}

private class PreviewApplication(base: Context) : Application() {
init {
attachBaseContext(base)
}
}

/**
* Remembers a THEOplayer view for use by a UI module.
*/
@InternalUiApi
@Composable
fun rememberTHEOplayerViewInternal(config: THEOplayerConfig?): THEOplayerView? {
return if (LocalInspectionMode.current) null else rememberTHEOplayerView(config)
}

/**
* Couples a THEOplayer view to the current lifecycle owner.
*/
@InternalUiApi
@Composable
fun setupTHEOplayerView(
theoplayerView: THEOplayerView,
destroyOnLifecycleDestroy: Boolean = true
): THEOplayerView {
var wasPlayingAd by remember { mutableStateOf(false) }

val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle, theoplayerView) {
val lifecycleObserver = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
theoplayerView.onResume()
if (wasPlayingAd) {
theoplayerView.player.play()
wasPlayingAd = false
}
}

Lifecycle.Event.ON_PAUSE -> {
wasPlayingAd = theoplayerView.player.ads.isPlaying
theoplayerView.onPause()
}

Lifecycle.Event.ON_DESTROY -> {
if (destroyOnLifecycleDestroy) {
theoplayerView.onDestroy()
}
}
else -> {}
}
}
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}

return theoplayerView
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.theoplayer.android.ui

import androidx.compose.runtime.mutableStateListOf
import com.theoplayer.android.api.THEOplayerView

/**
* Scope for the contents of a [UIController].
*/
interface UIControllerScope : MenuScope {
/**
* The player hosted in this [UIController].
*
* If the UIController was created from a [THEOplayerView], the [Player] is created on
* first access (and only then adds its listeners). Prefer [theoplayerView] with the
* experimental state holders instead.
*/
val player: Player

/**
* The THEOplayer view hosted in this [UIController], or `null` in inspection mode.
*/
val theoplayerView: THEOplayerView?
get() = player.theoplayerView
}

/**
* Default implementation of [UIControllerScope], managing the menu stack.
*/
@InternalUiApi
class UIControllerScopeImpl(
override val theoplayerView: THEOplayerView?,
private val providedPlayer: Player?
) : UIControllerScope {
private var menuStack = mutableStateListOf<MenuContent>()

val currentMenu: MenuContent?
get() = menuStack.lastOrNull()
var lastWasClosed: Boolean = false
private set

var activityInPipMode: Boolean = false
set(value) {
field = value
if (lazyPlayer.isInitialized()) {
lazyPlayer.value.activityInPipMode = value
}
}

private val lazyPlayer = lazy {
PlayerImpl(theoplayerView).also { it.activityInPipMode = activityInPipMode }
}

override val player: Player
get() = providedPlayer ?: lazyPlayer.value

fun dispose() {
if (lazyPlayer.isInitialized()) {
lazyPlayer.value.dispose()
}
}

override fun openMenu(menu: MenuContent) {
menuStack.add(menu)
lastWasClosed = false
}

override fun closeCurrentMenu() {
menuStack.removeLastOrNull()?.also {
lastWasClosed = true
}
}
}
35 changes: 0 additions & 35 deletions ui/src/main/java/com/theoplayer/android/ui/Menu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource

/**
* The content for a new menu, typically this should draw a single [Menu].
*/
typealias MenuContent = @Composable MenuScope.() -> Unit;

/**
* A menu that can be opened on top of the player.
*
Expand Down Expand Up @@ -71,33 +66,3 @@ fun MenuScope.Menu(
}
}
}

/**
* Scope for the contents of a [Menu].
*/
interface MenuScope {
/**
* Open a new submenu.
*
* When the submenu is closed, the original menu is shown again.
*
* Only the top-most menu is composed. While the submenu is open, the original menu
* (or the player controls, if no menu was open yet) leaves the composition, and any state
* remembered there is disposed. So instead of passing state from the caller into [menu],
* remember the state inside [menu]:
* ```kotlin
* // Don't: audioTrackState is disposed while the menu is open
* val audioTrackState = rememberAudioTrackState(player)
* openMenu { AudioTrackMenu(audioTrackState) }
*
* // Do
* openMenu { AudioTrackMenu(rememberAudioTrackState(player)) }
* ```
*/
fun openMenu(menu: MenuContent)

/**
* Close the currently open menu.
*/
fun closeCurrentMenu()
}
Loading
Loading