From 9d3bf1fd4a3ae86dca4fa7cf9b1a90fd73a8801b Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 1 Aug 2025 14:18:49 +0200 Subject: [PATCH 01/18] Add Bip Bop to sample streams --- .../main/java/com/theoplayer/android/ui/demo/Streams.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt index 3dcd58d..aaf0cce 100644 --- a/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt +++ b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt @@ -10,6 +10,13 @@ data class Stream(val title: String, val source: SourceDescription) val streams by lazy { listOf( + Stream( + title = "Bip Bop (HLS)", + source = SourceDescription.Builder( + TypedSource.Builder("https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8") + .build() + ).build() + ), Stream( title = "Elephant's Dream (HLS)", source = SourceDescription.Builder( From 183ea5529a68ec5e22ce02a67e891d27b8e3303c Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 1 Aug 2025 14:22:18 +0200 Subject: [PATCH 02/18] Add picture-in-picture button --- app/src/main/AndroidManifest.xml | 3 +- .../android/ui/demo/MainActivity.kt | 6 +- .../com/theoplayer/android/ui/DefaultUI.kt | 3 +- .../android/ui/PictureInPictureButton.kt | 62 +++++++++++++++++++ .../java/com/theoplayer/android/ui/Player.kt | 44 +++++++++++++ 5 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 33139e7..4b73460 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ tools:targetApi="31"> 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 5127a06..1c67cbb 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 @@ -30,6 +30,7 @@ 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.pip.PipConfiguration import com.theoplayer.android.ui.DefaultUI import com.theoplayer.android.ui.demo.nitflex.NitflexUI import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme @@ -59,7 +60,10 @@ fun MainContent() { val context = LocalContext.current val theoplayerView = remember(context) { - THEOplayerView(context).apply { + 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) 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 09b973f..2ae4c79 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) { + if (player.firstPlay && !player.pictureInPicture) { Row(verticalAlignment = Alignment.CenterVertically) { title?.let { Text( @@ -120,6 +120,7 @@ fun DefaultUI( ) } Spacer(modifier = Modifier.weight(1f)) + PictureInPictureButton() FullscreenButton() } } diff --git a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt new file mode 100644 index 0000000..bf1f009 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -0,0 +1,62 @@ +package com.theoplayer.android.ui + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Fullscreen +import androidx.compose.material.icons.rounded.PictureInPictureAlt +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.theoplayer.android.api.pip.PiPType + +/** + * A button that toggles picture-in-picture mode. + * + * @param modifier the [Modifier] to be applied to this button + * @param contentPadding the spacing values to apply internally between the container + * and the content + * @param pipType the type of the picture-in-picture window when entering + * @param enter button content when the player is not in picture-in-picture mode, + * typically an "enter picture-in-picture" icon + * @param exit button content when the player is in picture-in-picture mode, + * typically an "exit picture-in-picture" icon + */ +@Composable +fun PictureInPictureButton( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + pipType: PiPType = PiPType.ACTIVITY, + enter: @Composable () -> Unit = { + Icon( + Icons.Rounded.PictureInPictureAlt, + contentDescription = "Enter picture-in-picture" + ) + }, + exit: @Composable () -> Unit = { + Icon( + Icons.Rounded.Fullscreen, + contentDescription = "Exit picture-in-picture" + ) + } +) { + val player = Player.current + IconButton( + modifier = modifier, + contentPadding = contentPadding, + onClick = { + player?.let { + if (it.pictureInPicture) { + it.exitPictureInPicture() + } else { + it.enterPictureInPicture(pipType) + } + } + }) { + if (player?.pictureInPicture == true) { + 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 c744785..1d4c6f0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -29,6 +29,7 @@ import com.theoplayer.android.api.event.player.ErrorEvent import com.theoplayer.android.api.event.player.PauseEvent import com.theoplayer.android.api.event.player.PlayEvent import com.theoplayer.android.api.event.player.PlayerEventTypes +import com.theoplayer.android.api.event.player.PresentationModeChange import com.theoplayer.android.api.event.player.RateChangeEvent import com.theoplayer.android.api.event.player.ReadyStateChangeEvent import com.theoplayer.android.api.event.player.ResizeEvent @@ -41,6 +42,7 @@ import com.theoplayer.android.api.event.track.mediatrack.audio.list.AudioTrackLi import com.theoplayer.android.api.event.track.mediatrack.video.VideoTrackEventTypes import com.theoplayer.android.api.event.track.mediatrack.video.list.VideoTrackListEventTypes import com.theoplayer.android.api.event.track.texttrack.list.TextTrackListEventTypes +import com.theoplayer.android.api.pip.PiPType import com.theoplayer.android.api.player.ReadyState import com.theoplayer.android.api.player.track.mediatrack.MediaTrack import com.theoplayer.android.api.player.track.mediatrack.quality.AudioQuality @@ -183,6 +185,11 @@ interface Player { */ var fullscreen: Boolean + /** + * Returns whether the player is showing in picture-in-picture mode. + */ + val pictureInPicture: Boolean + /** * Returns whether the player is currently waiting for more data to resume playback. */ @@ -262,6 +269,16 @@ interface Player { */ fun pause() + /** + * Enter picture-in-picture mode. + */ + fun enterPictureInPicture(pipType: PiPType) + + /** + * Exit picture-in-picture mode. + */ + fun exitPictureInPicture() + /** * Contains properties to access the current [Player]. */ @@ -461,6 +478,24 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player val fullscreenListener = FullscreenHandler.OnFullscreenChangeListener { updateFullscreen() } + override var pictureInPicture: Boolean by mutableStateOf(false) + private set + + override fun enterPictureInPicture(pipType: PiPType) { + theoplayerView?.piPManager?.enterPiP(pipType) + } + + override fun exitPictureInPicture() { + theoplayerView?.piPManager?.exitPiP() + } + + private fun updatePictureInPicture() { + pictureInPicture = theoplayerView?.piPManager?.isInPiP ?: false + } + + val presentationModeChangeListener = + EventListener { updatePictureInPicture() } + override val loading by derivedStateOf { !paused && !ended && (seeking || readyState.ordinal < ReadyState.HAVE_FUTURE_DATA.ordinal) } @@ -670,6 +705,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateVolumeAndMuted() updatePlaybackRate() updateFullscreen() + updatePictureInPicture() updateVideoWidthAndHeight() updateActiveVideoTrack() updateAudioTracks() @@ -687,6 +723,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player player?.addEventListener(PlayerEventTypes.VOLUMECHANGE, volumeChangeListener) player?.addEventListener(PlayerEventTypes.RATECHANGE, rateChangeListener) player?.addEventListener(PlayerEventTypes.RESIZE, resizeListener) + player?.addEventListener( + PlayerEventTypes.PRESENTATIONMODECHANGE, + presentationModeChangeListener + ) player?.addEventListener(PlayerEventTypes.SOURCECHANGE, sourceChangeListener) player?.addEventListener(PlayerEventTypes.ERROR, errorListener) player?.videoTracks?.addEventListener( @@ -748,6 +788,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player player?.removeEventListener(PlayerEventTypes.VOLUMECHANGE, volumeChangeListener) player?.removeEventListener(PlayerEventTypes.RATECHANGE, rateChangeListener) player?.removeEventListener(PlayerEventTypes.RESIZE, resizeListener) + player?.removeEventListener( + PlayerEventTypes.PRESENTATIONMODECHANGE, + presentationModeChangeListener + ) player?.removeEventListener(PlayerEventTypes.SOURCECHANGE, sourceChangeListener) player?.removeEventListener(PlayerEventTypes.ERROR, errorListener) player?.videoTracks?.removeEventListener( From 25b90f21bc7e6ecc40f957db40d1a4eb3eec3dbc Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 1 Aug 2025 15:08:19 +0200 Subject: [PATCH 03/18] Show button only if picture-in-picture is supported --- .../android/ui/PictureInPictureButton.kt | 1 + .../java/com/theoplayer/android/ui/Player.kt | 10 ++++++++++ .../java/com/theoplayer/android/ui/Util.kt | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 ui/src/main/java/com/theoplayer/android/ui/Util.kt 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 bf1f009..3c1d56e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -41,6 +41,7 @@ fun PictureInPictureButton( } ) { val player = Player.current + if (player?.pictureInPictureSupported != true) return IconButton( modifier = modifier, contentPadding = contentPadding, 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 1d4c6f0..3b4b593 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -1,5 +1,6 @@ package com.theoplayer.android.ui +import android.app.Activity import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState @@ -190,6 +191,11 @@ interface Player { */ val pictureInPicture: Boolean + /** + * Returns whether the player supports entering picture-in-picture mode. + */ + val pictureInPictureSupported: Boolean + /** * Returns whether the player is currently waiting for more data to resume playback. */ @@ -481,6 +487,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player override var pictureInPicture: Boolean by mutableStateOf(false) private set + override val pictureInPictureSupported: Boolean by lazy { + (theoplayerView?.context as? Activity)?.supportsPictureInPictureMode() ?: false + } + override fun enterPictureInPicture(pipType: PiPType) { theoplayerView?.piPManager?.enterPiP(pipType) } diff --git a/ui/src/main/java/com/theoplayer/android/ui/Util.kt b/ui/src/main/java/com/theoplayer/android/ui/Util.kt new file mode 100644 index 0000000..95b8502 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/Util.kt @@ -0,0 +1,18 @@ +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 From 192fa14ebe944b55d6e667e50bbd035ea4edd70f Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 1 Aug 2025 15:50:33 +0200 Subject: [PATCH 04/18] Rework `FullscreenHandlerImpl` --- .../android/ui/FullscreenHandler.kt | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt b/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt index 51a7286..81192e4 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/FullscreenHandler.kt @@ -7,6 +7,11 @@ import android.view.ViewGroup import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine internal interface FullscreenHandler { val fullscreen: Boolean @@ -29,8 +34,13 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler private var previousViewParent: ViewGroup? = null private var previousViewIndex: Int = 0 private var previousViewLayoutParams: ViewGroup.LayoutParams? = null + private val scope = CoroutineScope(Dispatchers.Main) override fun requestFullscreen() { + scope.launch { requestFullscreenAsync() } + } + + suspend fun requestFullscreenAsync() { val activity = view.context as? Activity ?: return val window = activity.window @@ -53,15 +63,14 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler previousViewIndex = parent.indexOfChild(view) previousViewLayoutParams = view.layoutParams parent.removeView(view) - rootView.post { - rootView.addView( - view, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) + rootView.postAsync() + rootView.addView( + view, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ) - } + ) } fullscreen = true @@ -69,6 +78,10 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler } override fun exitFullscreen() { + scope.launch { exitFullscreenAsync() } + } + + private suspend fun exitFullscreenAsync() { val activity = view.context as? Activity ?: return val window = activity.window @@ -85,10 +98,9 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler val rootView = activity.findViewById(android.R.id.content) previousViewParent?.let { parent -> rootView.removeView(view) - parent.post { - parent.addView(view, previousViewIndex, previousViewLayoutParams) - view.layout(view.left, view.top, view.right, view.bottom) - } + parent.postAsync() + parent.addView(view, previousViewIndex, previousViewLayoutParams) + view.layout(view.left, view.top, view.right, view.bottom) } previousViewParent = null previousViewIndex = 0 @@ -96,4 +108,7 @@ internal class FullscreenHandlerImpl(private val view: View) : FullscreenHandler fullscreen = false onFullscreenChangeListener?.onFullscreenChange(fullscreen) } -} \ No newline at end of file +} + +private suspend fun View.postAsync() = + suspendCoroutine { continuation -> post { continuation.resume(Unit) } } \ No newline at end of file From 43f6f2972776affde98ad0d5f222ed4a9f2beb69 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Fri, 1 Aug 2025 16:00:09 +0200 Subject: [PATCH 05/18] Exit fullscreen before entering picture-in-picture --- ui/src/main/java/com/theoplayer/android/ui/Player.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 3b4b593..fc29bc7 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -466,6 +466,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player theoplayerView?.findViewById(com.theoplayer.android.R.id.theo_player_container) ?.let { FullscreenHandlerImpl(it) } private var _fullscreen by mutableStateOf(false) + private var onExitFullscreen: (() -> Unit)? = null override var fullscreen: Boolean get() = _fullscreen set(value) { @@ -479,6 +480,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player private fun updateFullscreen() { _fullscreen = fullscreenHandler?.fullscreen ?: false + if (!fullscreen) { + onExitFullscreen?.let { it() } + onExitFullscreen = null + } } val fullscreenListener = @@ -492,7 +497,12 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player } override fun enterPictureInPicture(pipType: PiPType) { - theoplayerView?.piPManager?.enterPiP(pipType) + if (fullscreen) { + onExitFullscreen = { theoplayerView?.piPManager?.enterPiP(pipType) } + fullscreen = false + } else { + theoplayerView?.piPManager?.enterPiP(pipType) + } } override fun exitPictureInPicture() { From 35f930a710ca00b857f649fa29866120e409a5f2 Mon Sep 17 00:00:00 2001 From: rbnbtns Date: Fri, 8 Aug 2025 10:46:36 +0200 Subject: [PATCH 06/18] Add localization for PiP --- app/src/main/res/values-nl/strings.xml | 3 +++ app/src/main/res/values/strings.xml | 3 +++ .../com/theoplayer/android/ui/PictureInPictureButton.kt | 5 +++-- ui/src/main/res/values/strings.xml | 9 +++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index e3a7129..06cacc1 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -39,4 +39,7 @@ Automatisch (%1$dp) Onbekend Fout + Start picture-in-picture + Stop picture-in-picture + Video speelt in PiP. \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 611cf98..de062c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,4 +50,7 @@ #kbps Unknown An error occurred + Enter picture-in-picture + Exit picture-in-picture + Video playing in PiP mode. \ No newline at end of file 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 3c1d56e..808a771 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.rounded.PictureInPictureAlt import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.theoplayer.android.api.pip.PiPType @@ -30,13 +31,13 @@ fun PictureInPictureButton( enter: @Composable () -> Unit = { Icon( Icons.Rounded.PictureInPictureAlt, - contentDescription = "Enter picture-in-picture" + contentDescription = stringResource(R.string.theoplayer_ui_pip_enter), ) }, exit: @Composable () -> Unit = { Icon( Icons.Rounded.Fullscreen, - contentDescription = "Exit picture-in-picture" + contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) ) } ) { diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 98c3479..18e947c 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -95,4 +95,13 @@ An error occurred + + + Enter picture-in-picture + + + Exit picture-in-picture + + + Video playing in PiP mode. \ No newline at end of file From b3d72bee621b24342a18754281295543600bd9c2 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 13:59:39 +0200 Subject: [PATCH 07/18] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 139794e..8fabe7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ > - 🏠 Internal > - 💅 Polish +## Unreleased + +* 🚀 Added `PictureInPictureButton`. ([#19](https://github.com/THEOplayer/android-ui/issues/19), [#70](https://github.com/THEOplayer/android-ui/pull/70)) + ## v1.11.1 (2025-08-01) * 🐛 Fixed clicking on overlays from OptiView Ads not working. ([#68](https://github.com/THEOplayer/android-ui/pull/68)) From 225ccd523cf63993bee11bbdcc77c05e80fe99bb Mon Sep 17 00:00:00 2001 From: OlegRyz Date: Tue, 19 Aug 2025 18:08:29 +0200 Subject: [PATCH 08/18] Show controls while playing AD --- ui/src/main/java/com/theoplayer/android/ui/UIController.kt | 2 -- 1 file changed, 2 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 9b207b8..e378101 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -158,8 +158,6 @@ fun UIController( false } else if (!player.firstPlay || player.castState == PlayerCastState.CONNECTED) { true - } else if (player.playingAd) { - false } else if (forceControlsHidden) { false } else { From c2401664246c34df28ed16fd8cc784cd10c3e6f4 Mon Sep 17 00:00:00 2001 From: OlegRyz Date: Tue, 19 Aug 2025 18:08:45 +0200 Subject: [PATCH 09/18] Disable seeking while playing ADs --- ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt | 2 ++ .../main/java/com/theoplayer/android/ui/SettingsMenuButton.kt | 3 +++ 2 files changed, 5 insertions(+) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index 2b3bf32..5799ccf 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -88,9 +88,11 @@ fun SeekButton( content: @Composable () -> Unit ) { val player = Player.current + val playingAd = player?.playingAd ?: false IconButton( modifier = modifier, contentPadding = contentPadding, + enabled = !playingAd, onClick = { player?.player?.let { if (!it.duration.isNaN()) { diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt index 7a10fd0..b8905e6 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt @@ -27,9 +27,12 @@ fun MenuScope.SettingsMenuButton( ) } ) { + val player = Player.current + val playingAd = player?.playingAd ?: false IconButton( modifier = modifier, contentPadding = contentPadding, + enabled = !playingAd, onClick = { openMenu { SettingsMenu() } }) { content() } From 4a4420019af7f9077d2087a8e8d198b1ac2ac686 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 14:08:48 +0200 Subject: [PATCH 10/18] Add `Player.canSeek` helper --- .../java/com/theoplayer/android/ui/Player.kt | 18 ++++++++++++++++++ .../java/com/theoplayer/android/ui/SeekBar.kt | 7 +------ .../com/theoplayer/android/ui/SeekButton.kt | 4 ++-- 3 files changed, 21 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 fc29bc7..ca4986d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -206,6 +206,13 @@ interface Player { */ val playingAd: Boolean + /** + * Returns whether the player can seek to a different time. + * + * Seeking may sometimes be prevented, for example because the player is [playing an ad][playingAd]. + */ + val canSeek: Boolean + /** * Returns the [StreamType] of the media. * @@ -345,6 +352,17 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player private set override var error by mutableStateOf(null) private set + override val canSeek by derivedStateOf { + if (playingAd) { + false + } else { + seekable.isNotEmpty() || run { + // `player.seekable` is (incorrectly) empty while casting, see #35 + // Temporary fix: always allow seeking while casting. + castState == PlayerCastState.CONNECTED + } + } + } private fun updateCurrentTime() { currentTime = player?.currentTime ?: 0.0 diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt index 26c751a..1134e23 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -29,7 +29,6 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.theoplayer.android.api.cast.chromecast.PlayerCastState /** * A seek bar showing the current time of the player, and which seeks the player when clicked or dragged. @@ -50,11 +49,7 @@ fun SeekBar( val currentTime = player?.currentTime ?: 0.0 val seekable = player?.seekable ?: TimeRanges.empty() val duration = player?.duration ?: Double.NaN - val playingAd = player?.playingAd ?: false - // `player.seekable` is (incorrectly) empty while casting, see #35 - // Temporary fix: always allow seeking while casting. - val casting = player?.castState == PlayerCastState.CONNECTED - val enabled = (seekable.isNotEmpty() && !playingAd) || casting + val enabled = player?.canSeek ?: false val seekableRange = remember(seekable, duration) { seekable.bounds ?: run { diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt index 5799ccf..5aeca4e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekButton.kt @@ -88,11 +88,11 @@ fun SeekButton( content: @Composable () -> Unit ) { val player = Player.current - val playingAd = player?.playingAd ?: false + val enabled = player?.canSeek ?: false IconButton( modifier = modifier, contentPadding = contentPadding, - enabled = !playingAd, + enabled = enabled, onClick = { player?.player?.let { if (!it.duration.isNaN()) { From 00a840e6460a003963b1222bb45716ae75dbe1a8 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 14:10:33 +0200 Subject: [PATCH 11/18] Hide seek and settings buttons in default UI while playing an ad --- .../main/java/com/theoplayer/android/ui/DefaultUI.kt | 10 ++++++---- .../com/theoplayer/android/ui/SettingsMenuButton.kt | 3 --- 2 files changed, 6 insertions(+), 7 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 2ae4c79..b896585 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -89,18 +89,20 @@ fun DefaultUI( ) } Spacer(modifier = Modifier.weight(1f)) - LanguageMenuButton() - SettingsMenuButton() + if (!player.playingAd) { + LanguageMenuButton() + SettingsMenuButton() + } ChromecastButton() } } }, centerChrome = { - if (player.firstPlay) { + if (player.firstPlay && !player.playingAd) { SeekButton(seekOffset = -10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } PlayButton(iconModifier = Modifier.size(96.dp), contentPadding = PaddingValues(8.dp)) - if (player.firstPlay) { + if (player.firstPlay && !player.playingAd) { SeekButton(seekOffset = 10, iconSize = 48.dp, contentPadding = PaddingValues(8.dp)) } }, diff --git a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt index b8905e6..7a10fd0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SettingsMenuButton.kt @@ -27,12 +27,9 @@ fun MenuScope.SettingsMenuButton( ) } ) { - val player = Player.current - val playingAd = player?.playingAd ?: false IconButton( modifier = modifier, contentPadding = contentPadding, - enabled = !playingAd, onClick = { openMenu { SettingsMenu() } }) { content() } From 9d00aa1eb054edf77f32d12af5179c22cd306cfa Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 14:16:58 +0200 Subject: [PATCH 12/18] Hide seekbar thumb while playing an ad --- .../main/java/com/theoplayer/android/ui/SeekBar.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt index 1134e23..2fc386e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -50,6 +50,7 @@ fun SeekBar( val seekable = player?.seekable ?: TimeRanges.empty() val duration = player?.duration ?: Double.NaN val enabled = player?.canSeek ?: false + val playingAd = player?.playingAd ?: false val seekableRange = remember(seekable, duration) { seekable.bounds ?: run { @@ -71,11 +72,13 @@ fun SeekBar( enabled = enabled, interactionSource = interactionSource, thumb = { - SeekBarThumb( - interactionSource = interactionSource, - colors = colors, - enabled = enabled - ) + if (!playingAd) { + SeekBarThumb( + interactionSource = interactionSource, + colors = colors, + enabled = enabled + ) + } }, track = { sliderState -> SliderDefaults.Track( From 46eaf3041ce578cef5563b9df8590a2cf2b6a0ef Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 14:21:00 +0200 Subject: [PATCH 13/18] Move seekbar to the bottom while playing an ad --- .../com/theoplayer/android/ui/DefaultUI.kt | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 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 b896585..6f83f7d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/DefaultUI.kt @@ -109,17 +109,23 @@ fun DefaultUI( bottomChrome = { if (player.firstPlay) { ChromecastDisplay(modifier = Modifier.padding(8.dp)) - if (player.streamType != StreamType.Live) { + if (!player.playingAd && player.streamType != StreamType.Live) { SeekBar() } Row(verticalAlignment = Alignment.CenterVertically) { MuteButton() - LiveButton() - if (player.streamType != StreamType.Live) { - CurrentTimeDisplay( - showRemaining = player.streamType == StreamType.Dvr, - showDuration = player.streamType == StreamType.Vod - ) + if (player.playingAd) { + if (player.streamType != StreamType.Live) { + SeekBar() + } + } else { + LiveButton() + if (player.streamType != StreamType.Live) { + CurrentTimeDisplay( + showRemaining = player.streamType == StreamType.Dvr, + showDuration = player.streamType == StreamType.Vod + ) + } } Spacer(modifier = Modifier.weight(1f)) PictureInPictureButton() From 96ec45dd94e262ffdca51a2a7ad469c2a02dbd7c Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Wed, 27 Aug 2025 14:26:18 +0200 Subject: [PATCH 14/18] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fabe7c..0c812d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ ## Unreleased * 🚀 Added `PictureInPictureButton`. ([#19](https://github.com/THEOplayer/android-ui/issues/19), [#70](https://github.com/THEOplayer/android-ui/pull/70)) +* 🚀 The default UI now shows a minimal set of controls while playing an ad. ([#71](https://github.com/THEOplayer/android-ui/pull/71)) +* 🚀 `UIController` no longer hides all controls while playing an ad. ([#71](https://github.com/THEOplayer/android-ui/pull/71)) ## v1.11.1 (2025-08-01) From beb9d571539d5e3b0a5514613838048425691a85 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 4 Sep 2025 13:21:05 +0200 Subject: [PATCH 15/18] Remove useless Surface in demo app --- .../com/theoplayer/android/ui/demo/MainActivity.kt | 11 +++++------ 1 file changed, 5 insertions(+), 6 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 1c67cbb..f8f922d 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 @@ -85,11 +85,10 @@ fun MainContent() { var themeMenuOpen by remember { mutableStateOf(false) } var theme by rememberSaveable { mutableStateOf(PlayerTheme.Default) } - Surface( + Scaffold( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - Scaffold(topBar = { + containerColor = MaterialTheme.colorScheme.background, + topBar = { TopAppBar( title = { Text(text = "Demo") @@ -109,7 +108,8 @@ fun MainContent() { } } ) - }) { padding -> + } + ) { padding -> val playerModifier = Modifier .padding(padding) .fillMaxSize(1f) @@ -154,7 +154,6 @@ fun MainContent() { onDismissRequest = { themeMenuOpen = false } ) } - } } } From ca5da8add96d5911f3846588e44ab60217744aef Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 4 Sep 2025 13:21:27 +0200 Subject: [PATCH 16/18] Fix indentation --- .../android/ui/demo/MainActivity.kt | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 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 f8f922d..304b713 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 @@ -110,50 +110,50 @@ fun MainContent() { ) } ) { padding -> - val playerModifier = Modifier - .padding(padding) - .fillMaxSize(1f) - when (theme) { - PlayerTheme.Default -> { - DefaultUI( + 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 ) } - - PlayerTheme.Nitflex -> { - NitflexTheme(useDarkTheme = true) { - NitflexUI( - modifier = playerModifier, - player = player, - title = stream.title - ) - } - } } + } - if (streamMenuOpen) { - SelectStreamDialog( - streams = streams, - currentStream = stream, - onSelectStream = { - stream = it - streamMenuOpen = false - }, - onDismissRequest = { streamMenuOpen = false } - ) - } - if (themeMenuOpen) { - SelectThemeDialog( - currentTheme = theme, - onSelectTheme = { - theme = it - themeMenuOpen = false - }, - onDismissRequest = { themeMenuOpen = false } - ) - } + if (streamMenuOpen) { + SelectStreamDialog( + streams = streams, + currentStream = stream, + onSelectStream = { + stream = it + streamMenuOpen = false + }, + onDismissRequest = { streamMenuOpen = false } + ) + } + if (themeMenuOpen) { + SelectThemeDialog( + currentTheme = theme, + onSelectTheme = { + theme = it + themeMenuOpen = false + }, + onDismissRequest = { themeMenuOpen = false } + ) + } } } From d4fb670bfdab667787af4560e6e17a849efd8646 Mon Sep 17 00:00:00 2001 From: Mattias Buelens Date: Thu, 4 Sep 2025 13:21:51 +0200 Subject: [PATCH 17/18] Use Enum.entries --- .../android/ui/demo/MainActivity.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 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 304b713..ed29d6b 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 @@ -15,9 +15,23 @@ 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.Refresh -import androidx.compose.material3.* -import androidx.compose.runtime.* +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 +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 @@ -217,7 +231,7 @@ fun SelectThemeDialog( style = MaterialTheme.typography.headlineSmall ) LazyColumn { - items(items = PlayerTheme.values()) { + items(items = PlayerTheme.entries) { ListItem( headlineContent = { Text(text = it.title) }, leadingContent = { From bc672cf946b8b18a86a6382873bddf7186425611 Mon Sep 17 00:00:00 2001 From: "theoplayer-bot[bot]" <873105+theoplayer-bot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:38:48 +0000 Subject: [PATCH 18/18] 1.12.0 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c812d5..0bbba61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ > - 🏠 Internal > - 💅 Polish -## Unreleased +## v1.12.0 (2025-09-08) * 🚀 Added `PictureInPictureButton`. ([#19](https://github.com/THEOplayer/android-ui/issues/19), [#70](https://github.com/THEOplayer/android-ui/pull/70)) * 🚀 The default UI now shows a minimal set of controls while playing an ad. ([#71](https://github.com/THEOplayer/android-ui/pull/71)) diff --git a/gradle.properties b/gradle.properties index 2c93375..297cb25 100644 --- a/gradle.properties +++ b/gradle.properties @@ -27,4 +27,4 @@ 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.11.1 +version=1.12.0