mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
[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:
parent
33c3d5140e
commit
28027c64fd
@ -87,7 +87,9 @@
|
|||||||
* Cast Extension:
|
* Cast Extension:
|
||||||
* Test Utilities:
|
* Test Utilities:
|
||||||
* Demo app:
|
* 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:
|
* Remove deprecated symbols:
|
||||||
|
|
||||||
## 1.6
|
## 1.6
|
||||||
|
@ -26,8 +26,12 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
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.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@ -35,6 +39,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
import androidx.media3.common.MediaItem
|
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.ExtraControls
|
||||||
import androidx.media3.demo.compose.buttons.MinimalControls
|
import androidx.media3.demo.compose.buttons.MinimalControls
|
||||||
import androidx.media3.demo.compose.data.videos
|
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.demo.compose.layout.noRippleClickable
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
|
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
|
||||||
import androidx.media3.ui.compose.state.rememberPresentationState
|
import androidx.media3.ui.compose.state.rememberPresentationState
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@ -101,17 +108,22 @@ private fun initializePlayer(context: Context): Player =
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
|
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
var currentContentScaleIndex by remember { mutableIntStateOf(0) }
|
||||||
|
val contentScale = CONTENT_SCALES[currentContentScaleIndex].second
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player)
|
val presentationState = rememberPresentationState(player)
|
||||||
|
val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
|
||||||
|
|
||||||
Box(modifier) {
|
Box(modifier) {
|
||||||
PlayerSurface(
|
PlayerSurface(
|
||||||
player = player,
|
player = player,
|
||||||
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
|
||||||
modifier = modifier.noRippleClickable { showControls = !showControls },
|
modifier = scaledModifier.noRippleClickable { showControls = !showControls },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!presentationState.showSurface) {
|
if (!presentationState.showSurface) {
|
||||||
// hide the surface that is being prepared behind a shutter
|
// hide the surface that is being prepared behind a shutter
|
||||||
|
// TODO: picking scaledModifier here makes the shutter invisible
|
||||||
Box(modifier.background(Color.Black))
|
Box(modifier.background(Color.Black))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,5 +138,12 @@ private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
|
|||||||
.navigationBarsPadding(),
|
.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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
)
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
@ -16,15 +16,20 @@
|
|||||||
|
|
||||||
package androidx.media3.ui.compose.state
|
package androidx.media3.ui.compose.state
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.C
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Timeline
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.VideoSize
|
||||||
import androidx.media3.common.listen
|
import androidx.media3.common.listen
|
||||||
import androidx.media3.common.util.UnstableApi
|
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
|
* State that holds information to correctly deal with UI components related to the rendering of
|
||||||
* frames to a surface.
|
* 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
|
* @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.
|
* 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
|
* @property[keepContentOnReset] whether the currently displayed video frame or media artwork is
|
||||||
@ -52,6 +64,9 @@ fun rememberPresentationState(player: Player): PresentationState {
|
|||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class PresentationState(private val player: Player) {
|
class PresentationState(private val player: Player) {
|
||||||
|
var videoSizeDp: Size? by mutableStateOf(getVideoSizeDp(player))
|
||||||
|
private set
|
||||||
|
|
||||||
var showSurface by mutableStateOf(false)
|
var showSurface by mutableStateOf(false)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@ -65,6 +80,11 @@ class PresentationState(private val player: Player) {
|
|||||||
|
|
||||||
suspend fun observe(): Nothing =
|
suspend fun observe(): Nothing =
|
||||||
player.listen { events ->
|
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)) {
|
if (events.contains(Player.EVENT_RENDERED_FIRST_FRAME)) {
|
||||||
showSurface = true
|
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) {
|
private fun maybeHideSurface(player: Player) {
|
||||||
val hasTracks =
|
val hasTracks =
|
||||||
player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty
|
player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty
|
||||||
|
Loading…
x
Reference in New Issue
Block a user