[ui-compose] Add PresentationState for first-frame info

It captures some information needed for the UI logic related to rendering of the video track (and later images) to the surface.

Supporting `EVENT_RENDERED_FIRST_FRAME` helps improve the UX by covering the surface with an overlay (scrim/shutter) until the first frame is ready. This helps avoid sudden flickering during MediaItem transitions.

PiperOrigin-RevId: 712889568
This commit is contained in:
jbibik 2025-01-07 06:31:28 -08:00 committed by Copybara-Service
parent 31e5142b72
commit 2dc6af1fae
3 changed files with 139 additions and 0 deletions

View File

@ -41,6 +41,8 @@
* IMA extension:
* Session:
* UI:
* Add `PresentationState` state holder class and the corresponding
`rememberPresentationState` Composable to `media3-ui-compose`.
* Downloads:
* OkHttp Extension:
* Cronet Extension:
@ -63,6 +65,7 @@
* Cast Extension:
* Test Utilities:
* Demo app:
* Use `PresentationState` to cover the `PlayerSurface` with an overlay.
* Remove deprecated symbols:
## 1.6

View File

@ -46,6 +46,7 @@ import androidx.media3.demo.compose.layout.noRippleClickable
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.rememberPresentationState
class MainActivity : ComponentActivity() {
@ -100,13 +101,22 @@ private fun initializePlayer(context: Context): Player =
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
var showControls by remember { mutableStateOf(true) }
val presentationState = rememberPresentationState(player)
Box(modifier) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = modifier.noRippleClickable { showControls = !showControls },
)
if (!presentationState.showSurface) {
// hide the surface that is being prepared behind a shutter
Box(modifier.background(Color.Black))
}
if (showControls) {
// drawn on top of a potential shutter
MinimalControls(player, Modifier.align(Alignment.Center))
ExtraControls(
player,

View File

@ -0,0 +1,126 @@
/*
* 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.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.listen
import androidx.media3.common.util.UnstableApi
/**
* Remembers the value of [PresentationState] created based on the passed [Player] and launches a
* coroutine to listen to [Player]'s changes. If the [Player] instance changes between compositions,
* produces and remembers a new value.
*/
@UnstableApi
@Composable
fun rememberPresentationState(player: Player): PresentationState {
val presentationState = remember(player) { PresentationState(player) }
LaunchedEffect(player) { presentationState.observe() }
return presentationState
}
/**
* State that holds information to correctly deal with UI components related to the rendering of
* frames to a surface.
*
* @property[showSurface] set to true when the Player emits [Player.EVENT_RENDERED_FIRST_FRAME] and
* reset to false on [Player.EVENT_TRACKS_CHANGED] depending on the number and type of tracks.
* @property[keepContentOnReset] whether the currently displayed video frame or media artwork is
* kept visible when tracks change. Defaults to false.
*/
@UnstableApi
class PresentationState(private val player: Player) {
var showSurface by mutableStateOf(false)
private set
var keepContentOnReset: Boolean = false
set(value) {
field = value
maybeHideSurface(player)
}
private var lastPeriodUidWithTracks: Any? = null
suspend fun observe(): Nothing =
player.listen { events ->
if (events.contains(Player.EVENT_RENDERED_FIRST_FRAME)) {
showSurface = true
}
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
maybeHideSurface(player)
}
}
private fun maybeHideSurface(player: Player) {
val hasTracks =
player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty
if (!shouldKeepSurfaceVisible(player)) {
if (!keepContentOnReset && !hasTracks) {
showSurface = false
}
if (hasTracks && !hasSelectedVideoTrack()) {
showSurface = false
}
}
}
private fun shouldKeepSurfaceVisible(player: Player): Boolean {
// Suppress the shutter if transitioning to an unprepared period within the same window. This
// is necessary to avoid closing the shutter (i.e hiding the surface) 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
}
val period = Timeline.Period()
if (player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty) {
lastPeriodUidWithTracks =
timeline.getPeriod(player.currentPeriodIndex, period, /* setIds= */ true).uid
} else
lastPeriodUidWithTracks?.let {
val lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(it)
if (lastPeriodIndexWithTracks != C.INDEX_UNSET) {
val lastWindowIndexWithTracks =
timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex
if (player.currentMediaItemIndex == lastWindowIndexWithTracks) {
// We're in the same media item, keep the surface visible, don't show the shutter.
return true
}
}
lastPeriodUidWithTracks = null
}
return false
}
private fun hasSelectedVideoTrack(): Boolean {
return player.isCommandAvailable(Player.COMMAND_GET_TRACKS) &&
player.currentTracks.isTypeSelected(C.TRACK_TYPE_VIDEO)
}
}