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