[ui-compose] Add aspect ratio to PresentationState

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

The video size will be correct only after the Player decoded the video onto the surface and can't be reliably extracted from MediaItem's metadata. The information about the video's true aspect ratio helps inform the UI elements (such as PlayerSurface Composable) how to customize the Modifiers.

Use this state in demo-compose to show off functionality of changing `PlayerSurface`'s aspectRatio

PiperOrigin-RevId: 714104260
This commit is contained in:
jbibik 2025-01-10 10:48:11 -08:00 committed by Copybara-Service
parent 33c3d5140e
commit 28027c64fd
5 changed files with 151 additions and 2 deletions

View File

@ -87,7 +87,9 @@
* Cast Extension:
* Test Utilities:
* Demo app:
* Use `PresentationState` to cover the `PlayerSurface` with an overlay.
* Use `PresentationState` to control the aspect ratio of `PlayerSurface`
Composable depending on the ContentScale type and cover it with a
shutter-overlay before the first frame is rendered.
* Remove deprecated symbols:
## 1.6

View File

@ -26,8 +26,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -35,6 +39,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.MediaItem
@ -42,10 +47,12 @@ 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.CONTENT_SCALES
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.modifiers.resizeWithContentScale
import androidx.media3.ui.compose.state.rememberPresentationState
class MainActivity : ComponentActivity() {
@ -101,17 +108,22 @@ private fun initializePlayer(context: Context): Player =
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
var showControls by remember { mutableStateOf(true) }
var currentContentScaleIndex by remember { mutableIntStateOf(0) }
val contentScale = CONTENT_SCALES[currentContentScaleIndex].second
val presentationState = rememberPresentationState(player)
val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
Box(modifier) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = modifier.noRippleClickable { showControls = !showControls },
modifier = scaledModifier.noRippleClickable { showControls = !showControls },
)
if (!presentationState.showSurface) {
// hide the surface that is being prepared behind a shutter
// TODO: picking scaledModifier here makes the shutter invisible
Box(modifier.background(Color.Black))
}
@ -126,5 +138,12 @@ private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
.navigationBarsPadding(),
)
}
Button(
onClick = { currentContentScaleIndex = currentContentScaleIndex.inc() % CONTENT_SCALES.size },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 48.dp),
) {
Text("ContentScale is ${CONTENT_SCALES[currentContentScaleIndex].first}")
}
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.demo.compose.layout
import androidx.compose.ui.layout.ContentScale
val CONTENT_SCALES =
listOf(
"Fit" to ContentScale.Fit,
"Crop" to ContentScale.Crop,
"None" to ContentScale.None,
"Inside" to ContentScale.Inside,
"FillBounds" to ContentScale.FillBounds,
"FillHeight" to ContentScale.FillHeight,
"FillWidth" to ContentScale.FillWidth,
)

View File

@ -0,0 +1,64 @@
/*
* Copyright 2025 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.modifiers
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.media3.common.util.UnstableApi
import kotlin.math.roundToInt
/**
* Attempts to size the original content rectangle to be inscribed into a destination by applying a
* specified [ContentScale] type.
*/
@UnstableApi
@Composable
fun Modifier.resizeWithContentScale(
contentScale: ContentScale,
sourceSizeDp: Size?,
density: Density = LocalDensity.current,
): Modifier =
then(
Modifier.fillMaxSize()
.wrapContentSize()
.then(
sourceSizeDp?.let { srcSizeDp ->
Modifier.layout { measurable, constraints ->
val srcSizePx =
with(density) { Size(Dp(srcSizeDp.width).toPx(), Dp(srcSizeDp.height).toPx()) }
val dstSizePx = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
val scaleFactor = contentScale.computeScaleFactor(srcSizePx, dstSizePx)
val placeable =
measurable.measure(
constraints.copy(
maxWidth = (srcSizePx.width * scaleFactor.scaleX).roundToInt(),
maxHeight = (srcSizePx.height * scaleFactor.scaleY).roundToInt(),
)
)
layout(placeable.width, placeable.height) { placeable.place(0, 0) }
}
} ?: Modifier
)
)

View File

@ -16,15 +16,20 @@
package androidx.media3.ui.compose.state
import androidx.annotation.Nullable
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.compose.ui.geometry.Size
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
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
@ -45,6 +50,13 @@ fun rememberPresentationState(player: Player): PresentationState {
* State that holds information to correctly deal with UI components related to the rendering of
* frames to a surface.
*
* @property[videoSizeDp] wraps [Player.getVideoSize] in Compose's [Size], becomes `null` when
* either height or width of the video is zero. Takes into account
* [VideoSize.pixelWidthHeightRatio] to return a Size in [Dp], i.e. device-independent pixel. To
* use this measurement in Compose's Drawing and Layout stages, convert it into pixels using
* [Density.toPx]. Note that for cases where `pixelWidthHeightRatio` is not equal to 1, the
* rescaling will be down, i.e. reducing the width or the height to achieve the same aspect ratio
* in square pixels.
* @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
@ -52,6 +64,9 @@ fun rememberPresentationState(player: Player): PresentationState {
*/
@UnstableApi
class PresentationState(private val player: Player) {
var videoSizeDp: Size? by mutableStateOf(getVideoSizeDp(player))
private set
var showSurface by mutableStateOf(false)
private set
@ -65,6 +80,11 @@ class PresentationState(private val player: Player) {
suspend fun observe(): Nothing =
player.listen { events ->
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
if (videoSize != VideoSize.UNKNOWN && playbackState != Player.STATE_IDLE) {
this@PresentationState.videoSizeDp = getVideoSizeDp(player)
}
}
if (events.contains(Player.EVENT_RENDERED_FIRST_FRAME)) {
showSurface = true
}
@ -73,6 +93,20 @@ class PresentationState(private val player: Player) {
}
}
@Nullable
private fun getVideoSizeDp(player: Player): Size? {
var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat())
if (videoSize.width == 0f || videoSize.height == 0f) return null
val par = player.videoSize.pixelWidthHeightRatio
if (par < 1.0) {
videoSize = videoSize.copy(width = videoSize.width * par)
} else if (par > 1.0) {
videoSize = videoSize.copy(height = videoSize.height / par)
}
return videoSize
}
private fun maybeHideSurface(player: Player) {
val hasTracks =
player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty