diff --git a/CHANGELOG.md b/CHANGELOG.md index 0114e9c..51f2f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6176a8f..7a13303 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt index ed29d6b..b673532 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/MainActivity.kt @@ -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 @@ -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 @@ -33,10 +37,12 @@ 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 @@ -44,40 +50,32 @@ 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) @@ -90,7 +88,79 @@ 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 @@ -98,6 +168,20 @@ fun MainContent() { 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(), @@ -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( @@ -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") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1869fa5..945a6a7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } @@ -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" } diff --git a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt index 6f83f7d..520b7f3 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -80,7 +80,7 @@ fun DefaultUI( } }, topChrome = { - if (player.firstPlay && !player.pictureInPicture) { + if (player.firstPlay) { Row(verticalAlignment = Alignment.CenterVertically) { title?.let { Text( diff --git a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt index 808a771..62f48fc 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -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() diff --git a/ui/src/main/java/com/theoplayer/android/ui/Player.kt b/ui/src/main/java/com/theoplayer/android/ui/Player.kt index d65f518..342eaf7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -52,6 +52,7 @@ import com.theoplayer.android.api.player.track.texttrack.TextTrack import com.theoplayer.android.api.player.track.texttrack.TextTrackKind import com.theoplayer.android.api.player.track.texttrack.TextTrackMode import com.theoplayer.android.api.source.SourceDescription +import com.theoplayer.android.ui.util.supportsPictureInPictureMode import com.theoplayer.android.api.event.track.mediatrack.audio.list.AddTrackEvent as AudioAddTrackEvent import com.theoplayer.android.api.event.track.mediatrack.audio.list.RemoveTrackEvent as AudioRemoveTrackEvent import com.theoplayer.android.api.event.track.mediatrack.audio.list.TrackListChangeEvent as AudioTrackListChangeEvent @@ -507,8 +508,12 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player val fullscreenListener = FullscreenHandler.OnFullscreenChangeListener { updateFullscreen() } - override var pictureInPicture: Boolean by mutableStateOf(false) - private set + internal var activityInPipMode: Boolean by mutableStateOf(false) + private var pipManagerInPipMode: Boolean by mutableStateOf(false) + + override val pictureInPicture: Boolean by derivedStateOf { + activityInPipMode || pipManagerInPipMode + } override val pictureInPictureSupported: Boolean by lazy { val theoplayerView = theoplayerView ?: return@lazy false @@ -530,7 +535,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } private fun updatePictureInPicture() { - pictureInPicture = theoplayerView?.piPManager?.isInPiP ?: false + pipManagerInPipMode = theoplayerView?.piPManager?.isInPiP == true } val presentationModeChangeListener = 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 cb1216c..6686ef8 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -50,6 +50,7 @@ import com.theoplayer.android.api.THEOplayerView 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 @@ -171,19 +172,18 @@ fun UIController( val uiState by remember { derivedStateOf { val currentMenu = scope.currentMenu - if (player.error != null) { - UIState.Error - } else if (currentMenu != null) { - UIState.Menu(currentMenu) - } else { - UIState.Controls + when { + player.pictureInPicture -> UIState.Hidden + player.error != null -> UIState.Error + currentMenu != null -> UIState.Menu(currentMenu) + else -> UIState.Controls } } } - val backgroundVisible = if (uiState is UIState.Controls) { - controlsVisible.value - } else { - true + val backgroundVisible = when (uiState) { + is UIState.Controls -> controlsVisible.value + is UIState.Hidden -> false + else -> true } val background by animateColorAsState( label = "BackgroundAnimation", @@ -270,6 +270,8 @@ fun UIController( bottomChrome = bottomChrome ) } + + is UIState.Hidden -> {} } } } @@ -311,6 +313,7 @@ private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() object Controls : UIState() + object Hidden } @Composable @@ -500,6 +503,8 @@ internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { } } + player.activityInPipMode = rememberIsInPipMode() + return player } diff --git a/ui/src/main/java/com/theoplayer/android/ui/Util.kt b/ui/src/main/java/com/theoplayer/android/ui/Util.kt deleted file mode 100644 index 95b8502..0000000 --- a/ui/src/main/java/com/theoplayer/android/ui/Util.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.theoplayer.android.ui - -import android.app.Activity -import android.content.pm.PackageManager -import android.os.Build - -// From android.content.pm.ActivityInfo -private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000 - -/** - * Check if the given activity supports [Activity.enterPictureInPictureMode]. - */ -internal fun Activity.supportsPictureInPictureMode(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false - if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false - val info = packageManager.getActivityInfo(componentName, 0) - return (info.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE) != 0 -} \ No newline at end of file diff --git a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt new file mode 100644 index 0000000..96a6da0 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -0,0 +1,63 @@ +package com.theoplayer.android.ui.util + +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.compose.LocalActivity +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.core.app.OnPictureInPictureModeChangedProvider +import androidx.core.app.OnPictureInPictureUiStateChangedProvider +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.app.PictureInPictureUiStateCompat +import androidx.core.util.Consumer + +/** + * From [android.content.pm.ActivityInfo] + */ +private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000 + +/** + * Check if the given activity supports [Activity.enterPictureInPictureMode]. + */ +internal fun Activity.supportsPictureInPictureMode(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + if (!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false + val info = packageManager.getActivityInfo(componentName, 0) + return (info.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE) != 0 +} + +/** + * Returns whether the activity is in (or transitioning to) picture-in-picture mode. + */ +@Composable +internal fun rememberIsInPipMode(): Boolean { + // https://developer.android.com/develop/ui/compose/system/picture-in-picture#handle-ui + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + val activity = LocalActivity.current ?: return false + var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) } + if (activity is OnPictureInPictureModeChangedProvider) { + DisposableEffect(activity) { + val observer = Consumer { info -> + pipMode = info.isInPictureInPictureMode + } + activity.addOnPictureInPictureModeChangedListener(observer) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + } + var transitioningToPip by remember { mutableStateOf(false) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && activity is OnPictureInPictureUiStateChangedProvider) { + DisposableEffect(activity) { + val observer = Consumer { info -> + transitioningToPip = info.isTransitioningToPip + } + activity.addOnPictureInPictureUiStateChangedListener(observer) + onDispose { activity.addOnPictureInPictureUiStateChangedListener(observer) } + } + } + return pipMode || transitioningToPip +} \ No newline at end of file