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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* 🐛 Changed the minimum supported THEOplayer version to 7.6.0. ([#85](https://github.com/THEOplayer/android-ui/pull/85))
* This was effectively already the minimum version as of Open Video UI for Android version 1.7.2, but it wasn't noticed until now.
* Future versions will be properly tested with the minimum supported THEOplayer version to avoid similar compatibility issues.
* 🐛 `Player.pictureInPicture` now also checks whether the `Activity` itself is in picture-in-picture mode, in case the activity has custom picture-in-picture logic (that does not use THEOplayer's `PiPManager` API). ([#89](https://github.com/THEOplayer/android-ui/pull/89/))

## v1.13.2 (2026-03-03)

Expand Down
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ dependencies {
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core)
implementation(libs.androidx.core.pip)
implementation(libs.androidx.compose.ui.ui)
implementation(libs.androidx.compose.ui.toolingPreview)
implementation(libs.androidx.compose.material3)
Expand Down
196 changes: 151 additions & 45 deletions app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.theoplayer.android.ui.demo

import android.content.res.Configuration
import android.os.Build
import android.os.Bundle
import android.util.Rational
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
Expand All @@ -14,9 +18,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Brush
import androidx.compose.material.icons.rounded.Movie
import androidx.compose.material.icons.rounded.PictureInPicture
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
Expand All @@ -33,51 +37,45 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.core.content.ContextCompat
import androidx.core.pip.PictureInPictureDelegate
import androidx.core.pip.VideoPlaybackPictureInPicture
import com.google.android.gms.cast.framework.CastContext
import com.theoplayer.android.api.THEOplayerConfig
import com.theoplayer.android.api.THEOplayerView
import com.theoplayer.android.api.ads.ima.GoogleImaIntegrationFactory
import com.theoplayer.android.api.cast.CastConfiguration
import com.theoplayer.android.api.cast.CastIntegrationFactory
import com.theoplayer.android.api.cast.CastStrategy
import com.theoplayer.android.api.event.player.PlayerEventTypes
import com.theoplayer.android.api.pip.PiPType
import com.theoplayer.android.api.pip.PipConfiguration
import com.theoplayer.android.ui.DefaultUI
import com.theoplayer.android.ui.Player
import com.theoplayer.android.ui.demo.nitflex.NitflexUI
import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme
import com.theoplayer.android.ui.rememberPlayer
import com.theoplayer.android.ui.theme.THEOplayerTheme
import androidx.compose.material3.Icon as Material3Icon

class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPictureEventListener {
private lateinit var theoplayerView: THEOplayerView
private lateinit var pip: VideoPlaybackPictureInPicture

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Initialize Chromecast immediately, for automatic receiver discovery to work correctly.
CastContext.getSharedInstance(this)

setContent {
THEOplayerTheme(useDarkTheme = true) {
MainContent()
}
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainContent() {
var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) }
var streamMenuOpen by remember { mutableStateOf(false) }

val context = LocalContext.current
val theoplayerView = remember(context) {
// Initialize THEOplayer
val config = THEOplayerConfig.Builder().apply {
pipConfiguration(PipConfiguration.Builder().build())
}.build()
THEOplayerView(context, config).apply {
theoplayerView = THEOplayerView(this, config).apply {
// Add ads integration through Google IMA
player.addIntegration(
GoogleImaIntegrationFactory.createGoogleImaIntegration(this)
Expand All @@ -90,14 +88,100 @@ fun MainContent() {
CastIntegrationFactory.createCastIntegration(this, castConfiguration)
)
}

initializePictureInPicture()

setContent {
THEOplayerTheme(useDarkTheme = true) {
MainContent(
theoplayerView = theoplayerView,
onEnterPip = ::enterPictureInPicture
)
}
}
}

private fun initializePictureInPicture() {
pip = VideoPlaybackPictureInPicture(this)
pip.addOnPictureInPictureEventListener(
ContextCompat.getMainExecutor(this),
this
)
pip.setAspectRatio(Rational(16, 9))
pip.setPlayerView(theoplayerView)
pip.setEnabled(true)

theoplayerView.player.addEventListener(PlayerEventTypes.RESIZE) { updatePictureInPictureAspectRatio() }
}

private fun enterPictureInPicture() {
theoplayerView.piPManager?.enterPiP(PiPType.CUSTOM)
}

private fun updatePictureInPictureAspectRatio() {
val player = theoplayerView.player
if (player.videoWidth > 0 && player.videoHeight > 0) {
pip.setAspectRatio(Rational(player.videoWidth, player.videoHeight))
}
}

override fun onPictureInPictureEvent(
event: PictureInPictureDelegate.Event,
config: Configuration?
) {
val pipManager = theoplayerView.piPManager ?: return
when (event) {
PictureInPictureDelegate.Event.ENTERED -> {
if (!pipManager.isInPiP) {
pipManager.enterPiP(PiPType.CUSTOM)
}
}

PictureInPictureDelegate.Event.EXITED -> {
if (pipManager.isInPiP) {
pipManager.exitPiP()
}
}
}
}

override fun onDestroy() {
super.onDestroy()
pip.close()
theoplayerView.onDestroy()
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainContent(
theoplayerView: THEOplayerView,
onEnterPip: () -> Unit = {}
) {
var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) }
var streamMenuOpen by remember { mutableStateOf(false) }

val player = rememberPlayer(theoplayerView)
LaunchedEffect(player, stream) {
player.source = stream.source
}

var themeMenuOpen by remember { mutableStateOf(false) }
var theme by rememberSaveable { mutableStateOf(PlayerTheme.Default) }
val activity = LocalActivity.current as? ComponentActivity

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true) {
// Only show player while in picture-in-picture mode
Surface(modifier = Modifier.fillMaxSize()) {
PlayerContent(
modifier = Modifier.fillMaxSize(),
player = player,
stream = stream,
theme = theme
)
}
return
}

Scaffold(
modifier = Modifier.fillMaxSize(),
Expand All @@ -108,44 +192,38 @@ fun MainContent() {
Text(text = "Demo")
},
actions = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
IconButton(onClick = onEnterPip) {
Material3Icon(
Icons.Rounded.PictureInPicture,
contentDescription = "Enter picture-in-picture"
)
}
}
IconButton(onClick = {
player.source = stream.source
player.play()
}) {
Icon(Icons.Rounded.Refresh, contentDescription = "Reload")
Material3Icon(Icons.Rounded.Refresh, contentDescription = "Reload")
}
IconButton(onClick = { streamMenuOpen = true }) {
Icon(Icons.Rounded.Movie, contentDescription = "Stream")
Material3Icon(Icons.Rounded.Movie, contentDescription = "Stream")
}
IconButton(onClick = { themeMenuOpen = true }) {
Icon(Icons.Rounded.Brush, contentDescription = "Theme")
Material3Icon(Icons.Rounded.Brush, contentDescription = "Theme")
}
}
)
}
) { padding ->
val playerModifier = Modifier
.padding(padding)
.fillMaxSize(1f)
when (theme) {
PlayerTheme.Default -> {
DefaultUI(
modifier = playerModifier,
player = player,
title = stream.title
)
}

PlayerTheme.Nitflex -> {
NitflexTheme(useDarkTheme = true) {
NitflexUI(
modifier = playerModifier,
player = player,
title = stream.title
)
}
}
}
PlayerContent(
modifier = Modifier
.padding(padding)
.fillMaxSize(1f),
player = player,
stream = stream,
theme = theme
)

if (streamMenuOpen) {
SelectStreamDialog(
Expand All @@ -171,6 +249,34 @@ fun MainContent() {
}
}

@Composable
fun PlayerContent(
modifier: Modifier = Modifier,
player: Player,
stream: Stream,
theme: PlayerTheme
) {
when (theme) {
PlayerTheme.Default -> {
DefaultUI(
modifier = modifier,
player = player,
title = stream.title
)
}

PlayerTheme.Nitflex -> {
NitflexTheme(useDarkTheme = true) {
NitflexUI(
modifier = modifier,
player = player,
title = stream.title
)
}
}
}
}

enum class PlayerTheme(val title: String) {
Default(title = "Default theme"),
Nitflex(title = "Nitflex theme")
Expand Down
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ gradle = "8.13.0"
kotlin = "2.2.10"
ktx = "1.17.0"
lifecycle-compose = "2.9.3"
activity-compose = "1.10.1"
activity-compose = "1.13.0"
appcompat = "1.7.1"
compose-bom = "2025.08.01"
junit4 = "4.13.2"
Expand All @@ -18,6 +18,8 @@ androidx-mediarouter = "1.8.1"
dokka = "2.0.0"
theoplayer = { prefer="10.11.0", strictly = "[7.6.0, 11.0)" }
theoplayer-min = { strictly = "7.6.0" }
core = "1.18.0"
core-pip = "1.0.0-alpha02"

[libraries]
androidx-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "ktx" }
Expand All @@ -36,6 +38,8 @@ androidx-compose-ui-toolingPreview = { group = "androidx.compose.ui", name = "ui
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" }
androidx-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidx-espresso" }
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" }
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" }
dokka-base = { group = "org.jetbrains.dokka", name = "dokka-base", version.ref = "dokka" }
Expand Down
2 changes: 1 addition & 1 deletion ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ fun DefaultUI(
}
},
topChrome = {
if (player.firstPlay && !player.pictureInPicture) {
if (player.firstPlay) {
Row(verticalAlignment = Alignment.CenterVertically) {
title?.let {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,21 +41,19 @@ fun PictureInPictureButton(
)
}
) {
val player = Player.current
if (player?.pictureInPictureSupported != true) return
val player = Player.current ?: return
if (!player.pictureInPictureSupported) return
IconButton(
modifier = modifier,
contentPadding = contentPadding,
onClick = {
player?.let {
if (it.pictureInPicture) {
it.exitPictureInPicture()
} else {
it.enterPictureInPicture(pipType)
}
if (player.pictureInPicture) {
player.exitPictureInPicture()
} else {
player.enterPictureInPicture(pipType)
}
}) {
if (player?.pictureInPicture == true) {
if (player.pictureInPicture) {
exit()
} else {
enter()
Expand Down
Loading