diff --git a/demos/compose/build.gradle b/demos/compose/build.gradle index 023ba912b9..e7a27c2140 100644 --- a/demos/compose/build.gradle +++ b/demos/compose/build.gradle @@ -70,6 +70,7 @@ dependencies { implementation composeBom implementation 'androidx.activity:activity-compose:1.9.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7' implementation 'androidx.compose.foundation:foundation' implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material:material-icons-extended' diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt index 32e8d02765..9a61cab7c3 100644 --- a/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt @@ -15,13 +15,13 @@ */ package androidx.media3.demo.compose +import android.content.Context +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -34,32 +34,66 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.demo.compose.buttons.ExtraControls import androidx.media3.demo.compose.buttons.MinimalControls import androidx.media3.demo.compose.data.videos +import androidx.media3.demo.compose.layout.noRippleClickable +import androidx.media3.demo.compose.layout.scaledWithAspectRatio import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.compose.PlayerSurface import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.state.rememberRenderingState class MainActivity : ComponentActivity() { - private lateinit var player: Player - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - player = initializePlayer() setContent { - MediaPlayerScreen(player = player, modifier = Modifier.fillMaxSize().navigationBarsPadding()) + MyApp() } } +} - private fun initializePlayer(): Player { - return ExoPlayer.Builder(this).build().apply { - setMediaItems(videos.map { MediaItem.fromUri(it) }) - prepare() +@Composable +fun MyApp(modifier: Modifier = Modifier) { + val context = LocalContext.current + var player by remember { mutableStateOf(null) } + if (Build.VERSION.SDK_INT > 23) { + LifecycleStartEffect(Unit) { + player = initializePlayer(context) + onStopOrDispose { releasePlayer(player); player = null } + } + } else { + LifecycleResumeEffect(Unit) { + player = initializePlayer(context) + onPauseOrDispose { releasePlayer(player); player = null } + } + } + player?.let { + MediaPlayerScreen( + player = it, + modifier = modifier.fillMaxSize().navigationBarsPadding() + ) + } +} + +private fun initializePlayer(context: Context): Player = + ExoPlayer.Builder(context).build().apply { + setMediaItems(videos.map(MediaItem::fromUri)) + prepare() + } + +private fun releasePlayer(player: Player?) { + player?.let { + if (player.availableCommands.contains(Player.COMMAND_RELEASE)) { + player.release() } } } @@ -67,18 +101,20 @@ class MainActivity : ComponentActivity() { @Composable private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) { var showControls by remember { mutableStateOf(true) } - Box(modifier) { + + val renderingState = rememberRenderingState(player) + val scaledModifier = modifier.scaledWithAspectRatio(ContentScale.Fit, renderingState.aspectRatio) + + Box(scaledModifier) { PlayerSurface( player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, // to prevent the ripple from the tap - ) { - showControls = !showControls - }, + modifier = Modifier.noRippleClickable { showControls = !showControls }, ) + if (!renderingState.renderedFirstFrame) { + // hide the surface that is being prepared behind a scrim + Box(scaledModifier.background(Color.Black)) + } if (showControls) { MinimalControls(player, Modifier.align(Alignment.Center)) ExtraControls( diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt index 42b8aef868..ed42d15d5a 100644 --- a/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/data/videos.kt @@ -18,5 +18,7 @@ package androidx.media3.demo.compose.data val videos = listOf( "https://html5demos.com/assets/dizzy.mp4", + "https://storage.googleapis.com/exoplayer-test-media-0/shortform_2.mp4", "https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm", + "https://storage.googleapis.com/exoplayer-test-media-0/shortform_3.mp4", ) diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/layout/Modifiers.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/layout/Modifiers.kt new file mode 100644 index 0000000000..0a42278fa9 --- /dev/null +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/layout/Modifiers.kt @@ -0,0 +1,53 @@ +package androidx.media3.demo.compose.layout + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale + +@Composable +internal fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier { + return then( + clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // to prevent the ripple from the tap + ) { + onClick() + } + ) +} + +@Composable +internal fun Modifier.scaledWithAspectRatio( + contentScale: ContentScale, + videoAspectRatio: Float, +): Modifier { + if (videoAspectRatio == 0f) { + // Video has not been decoded yet, let the component occupy incoming constraints + return Modifier + } + // TODO: decide if using aspectRatio is better than layout out the Composables ourselves + // ideally we would have aspectRatio( () -> videoAspectRatio ) to defer reads + return when (contentScale) { + ContentScale.FillWidth -> + then( + Modifier.fillMaxWidth().aspectRatio(videoAspectRatio, matchHeightConstraintsFirst = false) + ) + ContentScale.FillHeight -> + then( + Modifier.fillMaxHeight().aspectRatio(videoAspectRatio, matchHeightConstraintsFirst = true) + ) + ContentScale.Fit -> then(Modifier.aspectRatio(videoAspectRatio)) + // TODO: figure out how to implement these + // ContentScale.Crop -> ...? // like RESIZE_MODE_ZOOM? How to measure "deformation"? + // ContentScale.Inside -> ...? // no resizeMode equivalent, need to test with a tiny vid + // ContentScale.FillBounds -> then(Modifier)? like None? or is None like Inside? + ContentScale.None -> then(Modifier) + else -> then(Modifier) + } +} \ No newline at end of file diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RenderingState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RenderingState.kt new file mode 100644 index 0000000000..2c9ea80857 --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/RenderingState.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.media3.ui.compose.state + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.common.VideoSize +import androidx.media3.common.listen +import androidx.media3.common.util.UnstableApi + +@UnstableApi +@Composable +fun rememberRenderingState(player: Player): RenderingState { + val renderingState = remember(player) { RenderingState(player) } + LaunchedEffect(player) { renderingState.observe() } + return renderingState +} + +@UnstableApi +class RenderingState(private val player: Player) { + var aspectRatio by mutableFloatStateOf(getAspectRatio(player)) + private set + + var renderedFirstFrame by mutableStateOf(false) + private set + + var onRenderedFirstFrame: () -> Unit = {} + + var keepContentOnPlayerReset: Boolean = false + + private var lastPeriodUidWithTracks: Any? = null + + suspend fun observe(): Nothing = + player.listen { events -> + if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) { + if (videoSize != VideoSize.UNKNOWN) { + aspectRatio = getAspectRatio(player) + } + } + if (events.contains(Player.EVENT_RENDERED_FIRST_FRAME)) { + renderedFirstFrame = true + onRenderedFirstFrame() + } + if (events.contains(Player.EVENT_TRACKS_CHANGED)) { + // PlayerView's combo of updateForCurrentTrackSelections and onTracksChanged + if (!suppressShutter(player)) { + resetRenderedFirstFrame(player) + } + } + } + + private fun getAspectRatio(player: Player): Float { + val videoSize = player.videoSize + val width = videoSize.width + val height = videoSize.height + return if ((height == 0 || width == 0)) 0f + else (width * videoSize.pixelWidthHeightRatio) / height + } + + private fun resetRenderedFirstFrame(player: Player) { + val hasTracks = + player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty + if (!keepContentOnPlayerReset && !hasTracks) { + renderedFirstFrame = false + return + } + if (hasTracks && !hasSelectedVideoTrack()) { + renderedFirstFrame = false + return + } + } + + private fun suppressShutter(player: Player): Boolean { + // Suppress the update if transitioning to an unprepared period within the same window. This + // is necessary to avoid closing the shutter when such a transition occurs. See: + // https://github.com/google/ExoPlayer/issues/5507. + val timeline = + if (player.isCommandAvailable(Player.COMMAND_GET_TIMELINE)) player.currentTimeline + else Timeline.EMPTY + + if (timeline.isEmpty) { + lastPeriodUidWithTracks = null + return false + } else { + val period = Timeline.Period() + if (player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty) { + lastPeriodUidWithTracks = timeline.getPeriod(player.currentPeriodIndex, period, true).uid + } else { + if (lastPeriodUidWithTracks != null) { + val lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks!!) + if (lastPeriodIndexWithTracks != C.INDEX_UNSET) { + val lastWindowIndexWithTracks = + timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex + if (player.currentMediaItemIndex == lastWindowIndexWithTracks) { + // We're in the same media item. Suppress the update. + return true + } + } + lastPeriodUidWithTracks = null + } + } + } + return false + } + + private fun hasSelectedVideoTrack(): Boolean { + return player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && + player.currentTracks.isTypeSelected(C.TRACK_TYPE_VIDEO) + } +} \ No newline at end of file