From 2dc6af1fae29f2fbfa5c3ac43bc407c7db4f8565 Mon Sep 17 00:00:00 2001 From: jbibik Date: Tue, 7 Jan 2025 06:31:28 -0800 Subject: [PATCH] [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 --- RELEASENOTES.md | 3 + .../media3/demo/compose/MainActivity.kt | 10 ++ .../ui/compose/state/PresentationState.kt | 126 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PresentationState.kt diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 697df550a4..04611b014e 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 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 25aac48702..ba14749b14 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 @@ -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, diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PresentationState.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PresentationState.kt new file mode 100644 index 0000000000..742a8a578c --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/state/PresentationState.kt @@ -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) + } +}