diff --git a/CHANGELOG.md b/CHANGELOG.md index 139794e..0bbba61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ > - 🏠 Internal > - 💅 Polish +## 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)) +* 🚀 `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) * 🐛 Fixed clicking on overlays from OptiView Ads not working. ([#68](https://github.com/THEOplayer/android-ui/pull/68)) 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..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 @@ -30,6 +44,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 +74,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) @@ -81,11 +99,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") @@ -105,51 +122,51 @@ fun MainContent() { } } ) - }) { padding -> - val playerModifier = Modifier - .padding(padding) - .fillMaxSize(1f) - when (theme) { - PlayerTheme.Default -> { - DefaultUI( + } + ) { 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 ) } - - 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 } + ) } } } @@ -214,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 = { 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( 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/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 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..6f83f7d 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( @@ -89,37 +89,46 @@ 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)) } }, 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() FullscreenButton() } } 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 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..808a771 --- /dev/null +++ b/ui/src/main/java/com/theoplayer/android/ui/PictureInPictureButton.kt @@ -0,0 +1,64 @@ +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.res.stringResource +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 = stringResource(R.string.theoplayer_ui_pip_enter), + ) + }, + exit: @Composable () -> Unit = { + Icon( + Icons.Rounded.Fullscreen, + contentDescription = stringResource(R.string.theoplayer_ui_pip_exit) + ) + } +) { + val player = Player.current + if (player?.pictureInPictureSupported != true) return + 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..ca4986d 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 @@ -29,6 +30,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 +43,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 +186,16 @@ interface Player { */ var fullscreen: Boolean + /** + * Returns whether the player is showing in picture-in-picture mode. + */ + 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. */ @@ -193,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. * @@ -262,6 +282,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]. */ @@ -322,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 @@ -443,6 +484,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) { @@ -456,11 +498,42 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player private fun updateFullscreen() { _fullscreen = fullscreenHandler?.fullscreen ?: false + if (!fullscreen) { + onExitFullscreen?.let { it() } + onExitFullscreen = null + } } val fullscreenListener = FullscreenHandler.OnFullscreenChangeListener { updateFullscreen() } + 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) { + if (fullscreen) { + onExitFullscreen = { theoplayerView?.piPManager?.enterPiP(pipType) } + fullscreen = false + } else { + 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 +743,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateVolumeAndMuted() updatePlaybackRate() updateFullscreen() + updatePictureInPicture() updateVideoWidthAndHeight() updateActiveVideoTrack() updateAudioTracks() @@ -687,6 +761,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 +826,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( 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..2fc386e 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,8 @@ fun SeekBar( val currentTime = player?.currentTime ?: 0.0 val seekable = player?.seekable ?: TimeRanges.empty() val duration = player?.duration ?: Double.NaN + val enabled = player?.canSeek ?: false 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 seekableRange = remember(seekable, duration) { seekable.bounds ?: run { @@ -76,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( 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..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,9 +88,11 @@ fun SeekButton( content: @Composable () -> Unit ) { val player = Player.current + val enabled = player?.canSeek ?: false IconButton( modifier = modifier, contentPadding = contentPadding, + enabled = enabled, onClick = { player?.player?.let { if (!it.duration.isNaN()) { 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 { 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 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