From 28027c64fd7a85abeace5590f753458b0f827ba9 Mon Sep 17 00:00:00 2001 From: jbibik Date: Fri, 10 Jan 2025 10:48:11 -0800 Subject: [PATCH] [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 --- RELEASENOTES.md | 4 +- .../media3/demo/compose/MainActivity.kt | 21 +++++- .../demo/compose/layout/contentScales.kt | 30 +++++++++ .../media3/ui/compose/modifiers/extensions.kt | 64 +++++++++++++++++++ .../ui/compose/state/PresentationState.kt | 34 ++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 demos/compose/src/main/java/androidx/media3/demo/compose/layout/contentScales.kt create mode 100644 libraries/ui_compose/src/main/java/androidx/media3/ui/compose/modifiers/extensions.kt diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 4b7e3bbda7..1ebd1f35cb 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 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 ba14749b14..472b4e32fd 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 @@ -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}") + } } } diff --git a/demos/compose/src/main/java/androidx/media3/demo/compose/layout/contentScales.kt b/demos/compose/src/main/java/androidx/media3/demo/compose/layout/contentScales.kt new file mode 100644 index 0000000000..7a9c371c2a --- /dev/null +++ b/demos/compose/src/main/java/androidx/media3/demo/compose/layout/contentScales.kt @@ -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, + ) diff --git a/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/modifiers/extensions.kt b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/modifiers/extensions.kt new file mode 100644 index 0000000000..716ad0f99b --- /dev/null +++ b/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/modifiers/extensions.kt @@ -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 + ) + ) 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 index 742a8a578c..e043ec851a 100644 --- 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 @@ -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