From 736997628cf82387df51768fa624f9fe89db7422 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 10:46:18 +0100 Subject: [PATCH 01/14] Check if Activity is in picture-in-picture mode --- ui/src/main/java/com/theoplayer/android/ui/Player.kt | 5 ++++- ui/src/main/java/com/theoplayer/android/ui/Util.kt | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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..0bfb13a 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -530,7 +530,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } private fun updatePictureInPicture() { - pictureInPicture = theoplayerView?.piPManager?.isInPiP ?: false + val theoplayerView = theoplayerView ?: return + val activity = theoplayerView.context as? Activity + pictureInPicture = theoplayerView.piPManager?.isInPiP == true + || (activity?.isInPictureInPictureModeCompat() ?: false) } val presentationModeChangeListener = diff --git a/ui/src/main/java/com/theoplayer/android/ui/Util.kt b/ui/src/main/java/com/theoplayer/android/ui/Util.kt index 95b8502..eece45d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Util.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Util.kt @@ -4,7 +4,9 @@ import android.app.Activity import android.content.pm.PackageManager import android.os.Build -// From android.content.pm.ActivityInfo +/** + * From [android.content.pm.ActivityInfo] + */ private const val FLAG_SUPPORTS_PICTURE_IN_PICTURE = 0x400000 /** @@ -15,4 +17,8 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { 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 +} + +internal fun Activity.isInPictureInPictureModeCompat(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false } \ No newline at end of file From 2c6a5aca6c5ab6d012d1878421e6b506b61fc339 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 10:57:51 +0100 Subject: [PATCH 02/14] Move to PictureInPictureUtil --- ui/src/main/java/com/theoplayer/android/ui/Player.kt | 2 ++ .../android/ui/{Util.kt => util/PictureInPictureUtil.kt} | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename ui/src/main/java/com/theoplayer/android/ui/{Util.kt => util/PictureInPictureUtil.kt} (92%) 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 0bfb13a..419614b 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,8 @@ 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.isInPictureInPictureModeCompat +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 diff --git a/ui/src/main/java/com/theoplayer/android/ui/Util.kt b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt similarity index 92% rename from ui/src/main/java/com/theoplayer/android/ui/Util.kt rename to ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt index eece45d..5960705 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Util.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -1,4 +1,4 @@ -package com.theoplayer.android.ui +package com.theoplayer.android.ui.util import android.app.Activity import android.content.pm.PackageManager From f2871b8facd58326d99c50c44f3beaadb9e5f743 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 11:10:01 +0100 Subject: [PATCH 03/14] Listen for PIP mode changes --- .../java/com/theoplayer/android/ui/Player.kt | 11 +++---- .../com/theoplayer/android/ui/UIController.kt | 3 ++ .../android/ui/util/PictureInPictureUtil.kt | 31 +++++++++++++++++-- 3 files changed, 37 insertions(+), 8 deletions(-) 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 419614b..c08bebc 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -52,7 +52,6 @@ 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.isInPictureInPictureModeCompat 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 @@ -531,11 +530,11 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player theoplayerView?.piPManager?.exitPiP() } - private fun updatePictureInPicture() { - val theoplayerView = theoplayerView ?: return - val activity = theoplayerView.context as? Activity - pictureInPicture = theoplayerView.piPManager?.isInPiP == true - || (activity?.isInPictureInPictureModeCompat() ?: false) + internal var isActivityInPipMode: Boolean = false + + internal fun updatePictureInPicture() { + pictureInPicture = isActivityInPipMode + || 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..bf21669 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 @@ -500,6 +501,8 @@ internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { } } + player.isActivityInPipMode = rememberIsInPipMode() + return player } 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 index 5960705..390a273 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -3,6 +3,16 @@ 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.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer /** * From [android.content.pm.ActivityInfo] @@ -19,6 +29,23 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { return (info.flags and FLAG_SUPPORTS_PICTURE_IN_PICTURE) != 0 } -internal fun Activity.isInPictureInPictureModeCompat(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false +/** + * Returns whether the activity is in 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 + var pipMode by remember { mutableStateOf(activity?.isInPictureInPictureMode ?: false) } + if (activity is OnPictureInPictureModeChangedProvider) { + DisposableEffect(activity) { + val observer = Consumer { info -> + pipMode = info.isInPictureInPictureMode + } + activity.addOnPictureInPictureModeChangedListener(observer) + onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } + } + } + return pipMode } \ No newline at end of file From f25de993d018180651be3f335a7c7a72a1991a12 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:09:55 +0100 Subject: [PATCH 04/14] Fix `Player.pictureInPicture` not correctly updating --- .../main/java/com/theoplayer/android/ui/Player.kt | 15 ++++++++------- .../com/theoplayer/android/ui/UIController.kt | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) 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 c08bebc..342eaf7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -508,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,11 +534,8 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player theoplayerView?.piPManager?.exitPiP() } - internal var isActivityInPipMode: Boolean = false - - internal fun updatePictureInPicture() { - pictureInPicture = isActivityInPipMode - || theoplayerView?.piPManager?.isInPiP == true + private fun updatePictureInPicture() { + 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 bf21669..cf94ca4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -501,7 +501,7 @@ internal fun rememberPlayerInternal(theoplayerView: THEOplayerView?): Player { } } - player.isActivityInPipMode = rememberIsInPipMode() + player.activityInPipMode = rememberIsInPipMode() return player } From 7b3370f10f8d0955c9518f3b417b61898d3c59a0 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:10:26 +0100 Subject: [PATCH 05/14] Hide all UI controls when in PIP mode --- .../com/theoplayer/android/ui/UIController.kt | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) 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 cf94ca4..219ab76 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -172,7 +172,9 @@ fun UIController( val uiState by remember { derivedStateOf { val currentMenu = scope.currentMenu - if (player.error != null) { + if (player.pictureInPicture) { + UIState.Hidden + } else if (player.error != null) { UIState.Error } else if (currentMenu != null) { UIState.Menu(currentMenu) @@ -181,10 +183,10 @@ fun UIController( } } } - 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", @@ -271,6 +273,8 @@ fun UIController( bottomChrome = bottomChrome ) } + + is UIState.Hidden -> {} } } } @@ -312,6 +316,7 @@ private sealed class UIState { object Error : UIState() data class Menu(val menu: MenuContent) : UIState() object Controls : UIState() + object Hidden } @Composable From a8e98794f01644be1644118ce6f56690e6b07115 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:12:21 +0100 Subject: [PATCH 06/14] Extract PlayerContent composable --- .../android/ui/demo/MainActivity.kt | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) 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..8424a94 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 @@ -124,28 +124,14 @@ fun MainContent() { ) } ) { 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 +157,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") From 51b787dd7e17493da8d47175387088795a0b8fed Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:18:24 +0100 Subject: [PATCH 07/14] Add custom PIP button to sample app --- app/build.gradle.kts | 2 + .../android/ui/demo/MainActivity.kt | 82 ++++++++++++++++++- gradle/libs.versions.toml | 6 +- 3 files changed, 86 insertions(+), 4 deletions(-) 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 8424a94..994405b 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,6 +18,7 @@ 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 @@ -26,6 +31,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +43,10 @@ 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.app.PictureInPictureParamsCompat +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 @@ -46,29 +56,69 @@ import com.theoplayer.android.api.cast.CastIntegrationFactory import com.theoplayer.android.api.cast.CastStrategy 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 -class MainActivity : ComponentActivity() { +class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPictureEventListener { + private lateinit var pip: VideoPlaybackPictureInPicture + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Initialize Chromecast immediately, for automatic receiver discovery to work correctly. CastContext.getSharedInstance(this) + initializePictureInPicture() + setContent { THEOplayerTheme(useDarkTheme = true) { - MainContent() + MainContent( + pip = pip, + onEnterPip = ::enterPictureInPicture + ) } } } + + private fun initializePictureInPicture() { + pip = VideoPlaybackPictureInPicture(this) + pip.addOnPictureInPictureEventListener( + ContextCompat.getMainExecutor(this), + this + ) + pip.setAspectRatio(Rational(16, 9)) + pip.setEnabled(true) + } + + private fun enterPictureInPicture() { + val params = PictureInPictureParamsCompat.Builder().build().also { + pip.setPictureInPictureParams(it) + } + enterPictureInPictureMode(params) + } + + override fun onPictureInPictureEvent( + event: PictureInPictureDelegate.Event, + config: Configuration? + ) { + // Do nothing + } + + override fun onDestroy() { + super.onDestroy() + pip.close() + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MainContent() { +fun MainContent( + pip: VideoPlaybackPictureInPicture, + onEnterPip: () -> Unit = {} +) { var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) } var streamMenuOpen by remember { mutableStateOf(false) } @@ -95,9 +145,27 @@ fun MainContent() { LaunchedEffect(player, stream) { player.source = stream.source } + DisposableEffect(theoplayerView) { + pip.setPlayerView(theoplayerView) + onDispose { pip.setPlayerView(null) } + } 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,6 +176,14 @@ fun MainContent() { Text(text = "Demo") }, actions = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + IconButton(onClick = onEnterPip) { + Icon( + Icons.Rounded.PictureInPicture, + contentDescription = "Enter picture-in-picture" + ) + } + } IconButton(onClick = { player.source = stream.source player.play() 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" } From 73aaa139f9eb13aa7863c76a4ff203379a55d3ee Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:21:18 +0100 Subject: [PATCH 08/14] Create THEOplayerView in onCreate --- .../android/ui/demo/MainActivity.kt | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) 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 994405b..7f90e3a 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 @@ -31,7 +31,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -39,7 +38,6 @@ 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 @@ -63,6 +61,7 @@ import com.theoplayer.android.ui.rememberPlayer import com.theoplayer.android.ui.theme.THEOplayerTheme class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPictureEventListener { + private lateinit var theoplayerView: THEOplayerView private lateinit var pip: VideoPlaybackPictureInPicture override fun onCreate(savedInstanceState: Bundle?) { @@ -71,12 +70,30 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi // Initialize Chromecast immediately, for automatic receiver discovery to work correctly. CastContext.getSharedInstance(this) + // Initialize THEOplayer + val config = THEOplayerConfig.Builder().apply { + pipConfiguration(PipConfiguration.Builder().build()) + }.build() + theoplayerView = THEOplayerView(this, config).apply { + // Add ads integration through Google IMA + player.addIntegration( + GoogleImaIntegrationFactory.createGoogleImaIntegration(this) + ) + // Add Chromecast integration + val castConfiguration = CastConfiguration.Builder().apply { + castStrategy(CastStrategy.AUTO) + }.build() + player.addIntegration( + CastIntegrationFactory.createCastIntegration(this, castConfiguration) + ) + } + initializePictureInPicture() setContent { THEOplayerTheme(useDarkTheme = true) { MainContent( - pip = pip, + theoplayerView = theoplayerView, onEnterPip = ::enterPictureInPicture ) } @@ -90,6 +107,7 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi this ) pip.setAspectRatio(Rational(16, 9)) + pip.setPlayerView(theoplayerView) pip.setEnabled(true) } @@ -116,39 +134,16 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainContent( - pip: VideoPlaybackPictureInPicture, + theoplayerView: THEOplayerView, onEnterPip: () -> Unit = {} ) { var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) } var streamMenuOpen by remember { mutableStateOf(false) } - val context = LocalContext.current - val theoplayerView = remember(context) { - val config = THEOplayerConfig.Builder().apply { - pipConfiguration(PipConfiguration.Builder().build()) - }.build() - THEOplayerView(context, config).apply { - // Add ads integration through Google IMA - player.addIntegration( - GoogleImaIntegrationFactory.createGoogleImaIntegration(this) - ) - // Add Chromecast integration - val castConfiguration = CastConfiguration.Builder().apply { - castStrategy(CastStrategy.AUTO) - }.build() - player.addIntegration( - CastIntegrationFactory.createCastIntegration(this, castConfiguration) - ) - } - } val player = rememberPlayer(theoplayerView) LaunchedEffect(player, stream) { player.source = stream.source } - DisposableEffect(theoplayerView) { - pip.setPlayerView(theoplayerView) - onDispose { pip.setPlayerView(null) } - } var themeMenuOpen by remember { mutableStateOf(false) } var theme by rememberSaveable { mutableStateOf(PlayerTheme.Default) } From 3d874b02e28120f5d097f13680b229f00b29aeb5 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:29:24 +0100 Subject: [PATCH 09/14] Use `PiPType.CUSTOM` for custom PIP --- .../android/ui/demo/MainActivity.kt | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) 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 7f90e3a..aee9bd1 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 @@ -41,7 +41,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.core.app.PictureInPictureParamsCompat import androidx.core.content.ContextCompat import androidx.core.pip.PictureInPictureDelegate import androidx.core.pip.VideoPlaybackPictureInPicture @@ -52,6 +51,8 @@ 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 @@ -109,25 +110,45 @@ class MainActivity : ComponentActivity(), PictureInPictureDelegate.OnPictureInPi pip.setAspectRatio(Rational(16, 9)) pip.setPlayerView(theoplayerView) pip.setEnabled(true) + + theoplayerView.player.addEventListener(PlayerEventTypes.RESIZE) { updatePictureInPictureAspectRatio() } } private fun enterPictureInPicture() { - val params = PictureInPictureParamsCompat.Builder().build().also { - pip.setPictureInPictureParams(it) + 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)) } - enterPictureInPictureMode(params) } override fun onPictureInPictureEvent( event: PictureInPictureDelegate.Event, config: Configuration? ) { - // Do nothing + 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() } } From 27864655434b5ca97bb41b04ddb127062aa233c3 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 20 Mar 2026 14:54:57 +0100 Subject: [PATCH 10/14] Rename import --- .../com/theoplayer/android/ui/demo/MainActivity.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 aee9bd1..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 @@ -21,7 +21,6 @@ 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 @@ -60,6 +59,7 @@ 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 @@ -194,7 +194,7 @@ fun MainContent( actions = { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { IconButton(onClick = onEnterPip) { - Icon( + Material3Icon( Icons.Rounded.PictureInPicture, contentDescription = "Enter picture-in-picture" ) @@ -204,13 +204,13 @@ fun MainContent( 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") } } ) From 1dc53c06a1ecd7ef78df92a02696f79f7d6f784b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 23 Mar 2026 17:00:28 +0100 Subject: [PATCH 11/14] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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) From ca78d3c52ed8c138bb48f8b395fc693d741a9564 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 23 Mar 2026 17:24:27 +0100 Subject: [PATCH 12/14] Check if we're transitioning to PIP mode --- .../android/ui/util/PictureInPictureUtil.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 index 390a273..c0d673d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -11,7 +11,9 @@ 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 /** @@ -30,7 +32,7 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { } /** - * Returns whether the activity is in picture-in-picture mode. + * Returns whether the activity is in (or transitioning to) picture-in-picture mode. */ @Composable internal fun rememberIsInPipMode(): Boolean { @@ -47,5 +49,15 @@ internal fun rememberIsInPipMode(): Boolean { onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } } } - return pipMode + 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 From 63ba8aa2f46f6e17ef4cde3c117aae187b3031b1 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 23 Mar 2026 17:25:15 +0100 Subject: [PATCH 13/14] Optimize slightly --- .../com/theoplayer/android/ui/util/PictureInPictureUtil.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index c0d673d..96a6da0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/util/PictureInPictureUtil.kt @@ -38,8 +38,8 @@ internal fun Activity.supportsPictureInPictureMode(): Boolean { 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 - var pipMode by remember { mutableStateOf(activity?.isInPictureInPictureMode ?: false) } + val activity = LocalActivity.current ?: return false + var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) } if (activity is OnPictureInPictureModeChangedProvider) { DisposableEffect(activity) { val observer = Consumer { info -> From d1d4235479a2e9c9c9a1ba2ace5eb5f2d2dab87d Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Mon, 23 Mar 2026 17:27:19 +0100 Subject: [PATCH 14/14] Simplify --- .../java/com/theoplayer/android/ui/DefaultUI.kt | 2 +- .../android/ui/PictureInPictureButton.kt | 16 +++++++--------- .../com/theoplayer/android/ui/UIController.kt | 13 +++++-------- 3 files changed, 13 insertions(+), 18 deletions(-) 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/UIController.kt b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt index 219ab76..6686ef8 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -172,14 +172,11 @@ fun UIController( val uiState by remember { derivedStateOf { val currentMenu = scope.currentMenu - if (player.pictureInPicture) { - UIState.Hidden - } else 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 } } }