diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6891662..550d8c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,15 +16,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Run tests + run: ./gradlew test --configuration-cache - name: Assemble - run: ./gradlew assembleRelease + run: ./gradlew assembleRelease --configuration-cache diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 441594c..b38670a 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -18,13 +18,13 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - name: Create app token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token }} - name: Configure Git user diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 777996e..a39ec61 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -25,27 +25,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@v5 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: true + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Build API documentation with Dokka - run: ./gradlew ui:dokkaGeneratePublicationHtml + run: ./gradlew ui:dokkaGeneratePublicationHtml --configuration-cache - name: Build with Jekyll uses: actions/jekyll-build-pages@v1 with: source: ./site destination: ./_site - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 # Deployment job deploy: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fecd023..f32c73c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,13 +21,13 @@ jobs: id-token: write steps: - name: Create app token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token }} - name: Configure Git user @@ -35,16 +35,17 @@ jobs: git config user.name 'theoplayer-bot[bot]' git config user.email '873105+theoplayer-bot[bot]@users.noreply.github.com' - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - - name: Validate Gradle wrapper - uses: gradle/actions/wrapper-validation@v3 - name: Setup Gradle - uses: gradle/actions/setup-gradle@v3 + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: true + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Publish package - run: ./gradlew publish + run: ./gradlew publish --configuration-cache env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }} diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml index 1c8d03b..c0d5ff9 100644 --- a/.github/workflows/sync-develop.yml +++ b/.github/workflows/sync-develop.yml @@ -9,13 +9,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Create app token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@v3 id: app-token with: app-id: ${{ vars.THEOPLAYER_BOT_APP_ID }} private-key: ${{ secrets.THEOPLAYER_BOT_PRIVATE_KEY }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: token: ${{ steps.app-token.outputs.token }} ref: develop diff --git a/CHANGELOG.md b/CHANGELOG.md index e1a3293..ab01b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ > - 🏠 Internal > - 💅 Polish +## v1.13.3 (2026-03-23) + +* 🐛 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) * 🐛 Fixed `PictureInPictureButton` to only be shown when the backing `THEOplayerView` has a valid `PiPConfiguration`. ([#81](https://github.com/THEOplayer/android-ui/pull/81)) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1ecef39..7a13303 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -38,6 +38,19 @@ android { matchingFallbacks += listOf("debug") } } + + flavorDimensions += "player" + productFlavors { + create("latestPlayer") { + // Use the latest supported THEOplayer version + dimension = "player" + } + create("minPlayer") { + // Use the minimum supported THEOplayer version + dimension = "player" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 @@ -59,12 +72,18 @@ android { } dependencies { + val mavenImplementation = configurations.getByName("mavenImplementation") + val latestPlayerImplementation = configurations.getByName("latestPlayerImplementation") + val minPlayerImplementation = configurations.getByName("minPlayerImplementation") + implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.ktx) 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) @@ -80,9 +99,13 @@ dependencies { debugImplementation(project(":ui")) releaseImplementation(project(":ui")) - "mavenImplementation"("com.theoplayer.android-ui:android-ui:1.+") + mavenImplementation("com.theoplayer.android-ui:android-ui:1.+") - implementation(libs.theoplayer) - implementation(libs.theoplayer.ads.ima) - implementation(libs.theoplayer.cast) -} \ No newline at end of file + latestPlayerImplementation(libs.theoplayer) + latestPlayerImplementation(libs.theoplayer.ads.ima) + latestPlayerImplementation(libs.theoplayer.cast) + + minPlayerImplementation(libs.theoplayer.min) + minPlayerImplementation(libs.theoplayer.min.ads.ima) + minPlayerImplementation(libs.theoplayer.min.cast) +} 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 14e3ad5..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 { - pip(PipConfiguration.Builder().build()) + 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.properties b/gradle.properties index c396b12..19cd3b0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,4 +27,6 @@ org.gradle.configuration-cache=true org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true # The version of the THEOplayer Open Video UI for Android. -version=1.13.2 +version=1.13.3 +# (For testing purposes) Install the lowest supported THEOplayer version. +installLowestSupportedPlayerVersion=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b273d0..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" @@ -16,7 +16,10 @@ androidx-junit = "1.3.0" androidx-espresso = "3.7.0" androidx-mediarouter = "1.8.1" dokka = "2.0.0" -theoplayer = { prefer="10.11.0", strictly = "[5.0, 11.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" } @@ -35,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" } @@ -44,6 +49,9 @@ junit4 = { group = "junit", name = "junit", version.ref = "junit4" } theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" } theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" } theoplayer-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer" } +theoplayer-min = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer-min" } +theoplayer-min-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer-min" } +theoplayer-min-cast = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-cast", version.ref = "theoplayer-min" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle" } 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