diff --git a/CHANGELOG.md b/CHANGELOG.md index dc2b4d9..ba2bcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ > - 🏠 Internal > - 💅 Polish +## Unreleased + +* 💥 Updated to Jetpack Compose version 1.6.8 ([BOM](https://developer.android.com/jetpack/compose/bom) 2024.06.00). +* 🚀 Added basic support for advertisements. (Requires THEOplayer SDK version 7.10.0 or higher.) + ## v1.6.0 (2024-04-16) * 🚀 Added support for THEOplayer Android SDK version 7. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 85f747a..405c40f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,4 +77,6 @@ dependencies { "mavenImplementation"("com.theoplayer.android-ui:android-ui:1.+") implementation(libs.theoplayer) + implementation(libs.theoplayer.ads) + implementation(libs.theoplayer.ads.ima) } \ No newline at end of file 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 6618204..48dbae3 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 @@ -3,19 +3,27 @@ package com.theoplayer.android.ui.demo import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.Refresh import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog import com.theoplayer.android.api.THEOplayerConfig -import com.theoplayer.android.api.source.SourceDescription -import com.theoplayer.android.api.source.TypedSource +import com.theoplayer.android.api.ads.ima.GoogleImaIntegrationFactory import com.theoplayer.android.ui.DefaultUI import com.theoplayer.android.ui.demo.nitflex.NitflexUI import com.theoplayer.android.ui.demo.nitflex.theme.NitflexTheme @@ -37,14 +45,17 @@ class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainContent() { - val source = SourceDescription.Builder( - TypedSource.Builder("https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8") - .build() - ).build() + var stream by rememberSaveable(stateSaver = StreamSaver) { mutableStateOf(streams.first()) } + var streamMenuOpen by remember { mutableStateOf(false) } val player = rememberPlayer() - LaunchedEffect(player, source) { - player.source = source + LaunchedEffect(player) { + player.theoplayerView?.let { theoplayerView -> + theoplayerView.player.addIntegration(GoogleImaIntegrationFactory.createGoogleImaIntegration(theoplayerView)) + } + } + LaunchedEffect(player, stream) { + player.source = stream.source } var themeMenuOpen by remember { mutableStateOf(false) } @@ -61,27 +72,20 @@ fun MainContent() { }, actions = { IconButton(onClick = { - player.source = source + player.source = stream.source player.play() }) { Icon(Icons.Rounded.Refresh, contentDescription = "Reload") } + IconButton(onClick = { streamMenuOpen = true }) { + Icon(Icons.Rounded.Movie, contentDescription = "Stream") + } IconButton(onClick = { themeMenuOpen = true }) { Icon(Icons.Rounded.Brush, contentDescription = "Theme") } - DropdownMenu( - expanded = themeMenuOpen, - onDismissRequest = { themeMenuOpen = false }) { - DropdownMenuItem( - text = { Text(text = "Default theme") }, - onClick = { theme = PlayerTheme.Default }) - DropdownMenuItem( - text = { Text(text = "Nitflex theme") }, - onClick = { theme = PlayerTheme.Nitflex }) - } } ) - }, content = { padding -> + }) { padding -> val playerModifier = Modifier .padding(padding) .fillMaxSize(1f) @@ -90,7 +94,7 @@ fun MainContent() { DefaultUI( modifier = playerModifier, player = player, - title = "Elephant's Dream" + title = stream.title ) } @@ -99,18 +103,115 @@ fun MainContent() { NitflexUI( modifier = playerModifier, player = player, - title = "Elephant's Dream" + 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 } + ) + } + } + } +} + +enum class PlayerTheme(val title: String) { + Default(title = "Default theme"), + Nitflex(title = "Nitflex theme") +} + +@Composable +fun SelectStreamDialog( + streams: List, + currentStream: Stream, + onSelectStream: (Stream) -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Select a stream", + style = MaterialTheme.typography.headlineSmall + ) + LazyColumn { + items(items = streams) { + ListItem( + headlineContent = { Text(text = it.title) }, + leadingContent = { + RadioButton( + selected = (it == currentStream), + onClick = null + ) + }, + modifier = Modifier.clickable(onClick = { + onSelectStream(it) + }) ) } } } - }) + } } } -enum class PlayerTheme { - Default, - Nitflex +@Composable +fun SelectThemeDialog( + currentTheme: PlayerTheme, + onSelectTheme: (PlayerTheme) -> Unit, + onDismissRequest: () -> Unit, +) { + Dialog(onDismissRequest = onDismissRequest) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Select a theme", + style = MaterialTheme.typography.headlineSmall + ) + LazyColumn { + items(items = PlayerTheme.values()) { + ListItem( + headlineContent = { Text(text = it.title) }, + leadingContent = { + RadioButton( + selected = (it == currentTheme), + onClick = null + ) + }, + modifier = Modifier.clickable(onClick = { + onSelectTheme(it) + }) + ) + } + } + } + } + } } @Preview(showBackground = true) 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 new file mode 100644 index 0000000..a00ac32 --- /dev/null +++ b/app/src/main/java/com/theoplayer/android/ui/demo/Streams.kt @@ -0,0 +1,37 @@ +package com.theoplayer.android.ui.demo + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import com.theoplayer.android.api.source.SourceDescription +import com.theoplayer.android.api.source.TypedSource +import com.theoplayer.android.api.source.addescription.GoogleImaAdDescription + +data class Stream(val title: String, val source: SourceDescription) + +val streams by lazy { + listOf( + Stream( + title = "Elephant's Dream (HLS)", + source = SourceDescription.Builder( + TypedSource.Builder("https://cdn.theoplayer.com/video/elephants-dream/playlist.m3u8") + .build() + ).build() + ), + Stream( + title = "Sintel (DASH) with preroll ad", + source = SourceDescription.Builder( + TypedSource.Builder("https://cdn.theoplayer.com/video/dash/webvtt-embedded-in-isobmff/Manifest.mpd") + .build() + ).ads( + GoogleImaAdDescription.Builder("https://cdn.theoplayer.com/demos/ads/vast/dfp-preroll-no-skip.xml") + .timeOffset("start") + .build() + ).build() + ) + ) +} + +object StreamSaver : Saver { + override fun restore(value: Int): Stream? = streams.getOrNull(value) + override fun SaverScope.save(value: Stream): Int = streams.indexOf(value) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 63295c5..a7d4a47 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,15 @@ [versions] gradle = "8.3.2" kotlin-gradle-plugin = "1.8.10" -ktx = "1.12.0" -lifecycle-runtime = "2.7.0" -activity-compose = "1.8.2" -appcompat = "1.6.1" -compose-bom = "2024.04.00" +ktx = "1.13.1" +lifecycle-runtime = "2.8.4" +activity-compose = "1.9.1" +appcompat = "1.7.0" +compose-bom = "2024.06.00" junit4 = "4.13.2" -ui-test-junit4 = "1.6.5" # ...not in BOM for some reason? -androidx-junit = "1.1.5" -androidx-espresso = "3.5.1" +ui-test-junit4 = "1.6.8" # ...not in BOM for some reason? +androidx-junit = "1.2.1" +androidx-espresso = "3.6.1" dokka = "1.9.20" theoplayer = "7.8.0" @@ -35,6 +35,8 @@ dokka-plugin = { group = "org.jetbrains.dokka", name = "android-documentation-pl kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin-gradle-plugin" } junit4 = { group = "junit", name = "junit", version.ref = "junit4" } theoplayer = { group = "com.theoplayer.theoplayer-sdk-android", name = "core", version.ref = "theoplayer" } +theoplayer-ads = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads", version.ref = "theoplayer" } +theoplayer-ads-ima = { group = "com.theoplayer.theoplayer-sdk-android", name = "integration-ads-ima", version.ref = "theoplayer" } [plugins] android-application = { id = "com.android.application", version.ref = "gradle" } 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 be32c17..3c6fa8e 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/Player.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/Player.kt @@ -11,10 +11,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import com.theoplayer.android.api.THEOplayerView +import com.theoplayer.android.api.ads.Ads import com.theoplayer.android.api.cast.Cast import com.theoplayer.android.api.cast.chromecast.PlayerCastState import com.theoplayer.android.api.error.THEOplayerException import com.theoplayer.android.api.event.EventListener +import com.theoplayer.android.api.event.ads.AdEvent +import com.theoplayer.android.api.event.ads.AdsEventTypes import com.theoplayer.android.api.event.chromecast.CastErrorEvent import com.theoplayer.android.api.event.chromecast.CastStateChangeEvent import com.theoplayer.android.api.event.chromecast.ChromecastEventTypes @@ -92,6 +95,11 @@ interface Player { */ val cast: Cast? + /** + * Returns the raw [Ads] API of the backing THEOplayer instance. + */ + val ads: Ads? + /** * Returns the current playback position of the media, in seconds. */ @@ -178,6 +186,11 @@ interface Player { */ val loading: Boolean + /** + * Returns whether the player is currently playing an ad. + */ + val playingAd: Boolean + /** * Returns the [StreamType] of the media. * @@ -282,15 +295,18 @@ enum class StreamType { internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player { override val player = theoplayerView?.player + override val ads = theoplayerView?.player?.ads override val cast = theoplayerView?.cast override var currentTime by mutableStateOf(0.0) private set override var duration by mutableStateOf(Double.NaN) private set - override var seekable by mutableStateOf(TimeRanges(listOf())) + override var seekable by mutableStateOf(TimeRanges.empty()) private set override var paused by mutableStateOf(true) private set + override var playingAd by mutableStateOf(false) + private set override var ended by mutableStateOf(false) private set override var seeking by mutableStateOf(false) @@ -308,7 +324,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player private fun updateCurrentTime() { currentTime = player?.currentTime ?: 0.0 - seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges(listOf()) + seekable = player?.seekable?.let { TimeRanges.fromTHEOplayer(it) } ?: TimeRanges.empty() } private fun updateDuration() { @@ -331,6 +347,10 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player videoHeight = player?.videoHeight ?: 0 } + private fun updatePlayingAd() { + playingAd = player?.ads?.isPlaying ?: false + } + private val playListener = EventListener { updateCurrentTimeAndPlaybackState() } private val pauseListener = EventListener { updateCurrentTimeAndPlaybackState() } private val endedListener = EventListener { updateCurrentTimeAndPlaybackState() } @@ -342,6 +362,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player private val readyStateChangeListener = EventListener { updateCurrentTimeAndPlaybackState() } private val resizeListener = EventListener { updateVideoWidthAndHeight() } + private val adListener = EventListener> { updatePlayingAd() } private val sourceChangeListener = EventListener { _source = player?.source error = null @@ -350,6 +371,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateDuration() updateVideoWidthAndHeight() updateActiveVideoTrack() + updatePlayingAd() } private val errorListener = EventListener { event -> _source = player?.source @@ -358,6 +380,7 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player updateDuration() updateVideoWidthAndHeight() updateActiveVideoTrack() + updatePlayingAd() } override fun play() { @@ -657,6 +680,8 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player TextTrackListEventTypes.TRACKLISTCHANGE, textTrackListChangeListener ) + ads?.addEventListener(AdsEventTypes.AD_BEGIN, adListener) + ads?.addEventListener(AdsEventTypes.AD_END, adListener) cast?.chromecast?.addEventListener( ChromecastEventTypes.STATECHANGE, chromecastStateChangeListener @@ -720,6 +745,8 @@ internal class PlayerImpl(override val theoplayerView: THEOplayerView?) : Player TextTrackListEventTypes.TRACKLISTCHANGE, textTrackListChangeListener ) + ads?.removeEventListener(AdsEventTypes.AD_BEGIN, adListener) + ads?.removeEventListener(AdsEventTypes.AD_END, adListener) cast?.chromecast?.removeEventListener( ChromecastEventTypes.STATECHANGE, chromecastStateChangeListener 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 39fe984..012e686 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/SeekBar.kt @@ -26,11 +26,17 @@ fun SeekBar( ) { val player = Player.current val currentTime = player?.currentTime?.toFloat() ?: 0.0f - val seekable = player?.seekable ?: TimeRanges(listOf()) + val seekable = player?.seekable ?: TimeRanges.empty() + val duration = player?.duration ?: Double.NaN + val playingAd = player?.playingAd ?: false + val enabled = seekable.isNotEmpty() && !playingAd - val valueRange = remember(seekable) { - val bounds = seekable.bounds ?: 0.0..0.0 - bounds.start.toFloat()..bounds.endInclusive.toFloat() + val valueRange = remember(seekable, duration) { + seekable.bounds?.let { bounds -> + bounds.start.toFloat()..bounds.endInclusive.toFloat() + } ?: run { + 0f..(if (duration.isFinite()) duration.toFloat() else 0f) + } } var seekTime by remember { mutableStateOf(null) } var wasPlayingBeforeSeek by remember { mutableStateOf(false) } @@ -40,7 +46,7 @@ fun SeekBar( colors = colors, value = seekTime ?: currentTime, valueRange = valueRange, - enabled = seekable.isNotEmpty(), + enabled = enabled, onValueChange = remember { { time -> seekTime = time diff --git a/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt b/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt index 7320bf5..39fe28d 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/TimeRanges.kt @@ -31,6 +31,14 @@ data class TimeRanges(private val ranges: List> } companion object { + private val _empty by lazy(mode = LazyThreadSafetyMode.NONE) { TimeRanges(listOf()) } + + /** + * Returns an empty [TimeRanges]. + */ + @JvmStatic + fun empty(): TimeRanges = _empty + /** * Converts a [com.theoplayer.android.api.timerange.TimeRanges] to a [TimeRanges]. */ 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 fbe9cfa..20d5ea0 100644 --- a/ui/src/main/java/com/theoplayer/android/ui/UIController.kt +++ b/ui/src/main/java/com/theoplayer/android/ui/UIController.kt @@ -102,7 +102,6 @@ fun UIController( * @param bottomChrome controls to show at the bottom of the player, for example a [SeekBar] * or a [Row] containing a [MuteButton] and a [FullscreenButton]. */ -@OptIn(ExperimentalAnimationApi::class) @Composable fun UIController( modifier: Modifier = Modifier, @@ -141,6 +140,8 @@ fun UIController( false } else if (!player.firstPlay) { true + } else if (player.playingAd) { + false } else if (forceControlsHidden) { false } else { @@ -240,7 +241,7 @@ fun UIController( is UIState.Controls -> { scope.PlayerControls( controlsVisible = controlsVisible.value, - animationsActive = isReady, + animationsActive = isReady && !player.playingAd, centerOverlay = centerOverlay, topChrome = topChrome, centerChrome = centerChrome, @@ -441,6 +442,7 @@ fun rememberPlayer(config: THEOplayerConfig? = null): Player { internal fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplayerView { val context = LocalContext.current val theoplayerView = remember { THEOplayerView(context, config) } + var wasPlayingAd by remember { mutableStateOf(false) } DisposableEffect(theoplayerView) { onDispose { @@ -452,8 +454,19 @@ internal fun rememberTHEOplayerView(config: THEOplayerConfig? = null): THEOplaye DisposableEffect(lifecycle, theoplayerView) { val lifecycleObserver = LifecycleEventObserver { _, event -> when (event) { - Lifecycle.Event.ON_RESUME -> theoplayerView.onResume() - Lifecycle.Event.ON_PAUSE -> theoplayerView.onPause() + Lifecycle.Event.ON_RESUME -> { + theoplayerView.onResume() + if (wasPlayingAd) { + theoplayerView.player.play() + wasPlayingAd = false + } + } + + Lifecycle.Event.ON_PAUSE -> { + wasPlayingAd = theoplayerView.player.ads.isPlaying + theoplayerView.onPause() + } + Lifecycle.Event.ON_DESTROY -> theoplayerView.onDestroy() else -> {} }