Skip to content

Commit 5358447

Browse files
oceanjulescopybara-github
authored andcommitted
[ui-compose] Add ContentFrame - PlayerSurface with shutter and scaling
`ContentFrame` Composable will be the top level Composable for everything related to the display of media content. It aims to match the responsibility of the `AspectRatioFrameLayout` of `contentFrame` in the `PlayerView` world. Current functionality includes covering with a shutter (black box by default) when the surface is not ready and resizing of the container according to the provided `ContentScale`. In the future, this is the Composable that will host other subcomposables like artwork, image output, subtitles and error overlay. PiperOrigin-RevId: 795452026
1 parent 0a4363e commit 5358447

File tree

4 files changed

+178
-17
lines changed

4 files changed

+178
-17
lines changed

RELEASENOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@
144144
`rememberProgressStateWithTickCount` Composable to `media3-ui-compose`
145145
module. This state holder is used in `demo-compose` to display progress
146146
as a horizontal read-only progress bar.
147+
* Add `ContentFrame` Composable to `media3-ui-compose` which combines
148+
`PlayerSurface` management with aspect ratio resizing and covering with
149+
a shutter.
147150
* Downloads:
148151
* OkHttp extension:
149152
* Cronet extension:

demos/compose/src/main/java/androidx/media3/demo/compose/MainActivity.kt

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,8 @@ import androidx.media3.demo.compose.indicator.HorizontalLinearProgressIndicator
5151
import androidx.media3.demo.compose.layout.CONTENT_SCALES
5252
import androidx.media3.demo.compose.layout.noRippleClickable
5353
import androidx.media3.exoplayer.ExoPlayer
54-
import androidx.media3.ui.compose.PlayerSurface
54+
import androidx.media3.ui.compose.ContentFrame
5555
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
56-
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
57-
import androidx.media3.ui.compose.state.rememberPresentationState
5856

5957
class MainActivity : ComponentActivity() {
6058

@@ -116,26 +114,16 @@ private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
116114
var currentContentScaleIndex by remember { mutableIntStateOf(0) }
117115
val contentScale = CONTENT_SCALES[currentContentScaleIndex].second
118116

119-
val presentationState = rememberPresentationState(player)
120-
val scaledModifier = Modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
121-
122117
// Only use MediaPlayerScreen's modifier once for the top level Composable
123118
Box(modifier) {
124-
// Always leave PlayerSurface to be part of the Compose tree because it will be initialised in
125-
// the process. If this composable is guarded by some condition, it might never become visible
126-
// because the Player will not emit the relevant event, e.g. the first frame being ready.
127-
PlayerSurface(
119+
ContentFrame(
128120
player = player,
129121
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
130-
modifier = scaledModifier.noRippleClickable { showControls = !showControls },
122+
modifier = Modifier.noRippleClickable { showControls = !showControls },
123+
keepContentOnReset = true,
124+
contentScale = contentScale,
131125
)
132126

133-
if (presentationState.coverSurface) {
134-
// Cover the surface that is being prepared with a shutter
135-
// Do not use scaledModifier here, makes the Box be measured at 0x0
136-
Box(Modifier.matchParentSize().background(Color.Black))
137-
}
138-
139127
if (showControls) {
140128
// drawn on top of a potential shutter
141129
MinimalControls(player, Modifier.fillMaxWidth().align(Alignment.Center))
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.media3.ui.compose
18+
19+
import androidx.compose.foundation.background
20+
import androidx.compose.foundation.layout.Box
21+
import androidx.compose.foundation.layout.fillMaxSize
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.graphics.Color
25+
import androidx.compose.ui.layout.ContentScale
26+
import androidx.media3.common.Player
27+
import androidx.media3.common.util.UnstableApi
28+
import androidx.media3.ui.compose.modifiers.resizeWithContentScale
29+
import androidx.media3.ui.compose.state.rememberPresentationState
30+
31+
@UnstableApi
32+
@Composable
33+
fun ContentFrame(
34+
player: Player?,
35+
modifier: Modifier = Modifier,
36+
surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
37+
contentScale: ContentScale = ContentScale.Fit,
38+
keepContentOnReset: Boolean = false,
39+
shutter: @Composable () -> Unit = { Box(Modifier.fillMaxSize().background(Color.Black)) },
40+
) {
41+
val presentationState = rememberPresentationState(player, keepContentOnReset)
42+
val scaledModifier = modifier.resizeWithContentScale(contentScale, presentationState.videoSizeDp)
43+
44+
// Always leave PlayerSurface to be part of the Compose tree because it will be initialised in
45+
// the process. If this composable is guarded by some condition, it might never become visible
46+
// because the Player will not emit the relevant event, e.g. the first frame being ready.
47+
PlayerSurface(player, scaledModifier, surfaceType)
48+
49+
if (presentationState.coverSurface) {
50+
shutter()
51+
}
52+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.media3.ui.compose
18+
19+
import android.view.SurfaceView
20+
import android.view.TextureView
21+
import androidx.compose.foundation.layout.Box
22+
import androidx.compose.runtime.MutableState
23+
import androidx.compose.runtime.mutableStateOf
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.ui.Modifier
26+
import androidx.compose.ui.layout.ContentScale
27+
import androidx.compose.ui.platform.testTag
28+
import androidx.compose.ui.test.getBoundsInRoot
29+
import androidx.compose.ui.test.junit4.createComposeRule
30+
import androidx.compose.ui.test.onNodeWithTag
31+
import androidx.compose.ui.unit.height
32+
import androidx.compose.ui.unit.width
33+
import androidx.media3.common.VideoSize
34+
import androidx.media3.ui.compose.utils.TestPlayer
35+
import androidx.test.ext.junit.runners.AndroidJUnit4
36+
import com.google.common.truth.Truth.assertThat
37+
import kotlin.math.abs
38+
import org.junit.Rule
39+
import org.junit.Test
40+
import org.junit.runner.RunWith
41+
42+
/** Unit test for [ContentFrame]. */
43+
@RunWith(AndroidJUnit4::class)
44+
class ContentFrameTest {
45+
46+
@get:Rule val composeTestRule = createComposeRule()
47+
48+
@Test
49+
fun contentFrame_withSurfaceViewType_setsSurfaceViewOnPlayer() {
50+
val player = TestPlayer()
51+
52+
composeTestRule.setContent {
53+
ContentFrame(player = player, surfaceType = SURFACE_TYPE_SURFACE_VIEW)
54+
}
55+
56+
assertThat(player.videoOutput).isInstanceOf(SurfaceView::class.java)
57+
}
58+
59+
@Test
60+
fun contentFrame_withTextureViewType_setsTextureViewOnPlayer() {
61+
val player = TestPlayer()
62+
63+
composeTestRule.setContent {
64+
ContentFrame(player = player, surfaceType = SURFACE_TYPE_TEXTURE_VIEW)
65+
}
66+
67+
assertThat(player.videoOutput).isInstanceOf(TextureView::class.java)
68+
}
69+
70+
@Test
71+
fun contentFrame_withIdlePlayer_shutterShown() {
72+
val player = TestPlayer()
73+
74+
composeTestRule.setContent { ContentFrame(player) { Box(Modifier.testTag("Shutter")) } }
75+
76+
composeTestRule.onNodeWithTag("Shutter").assertExists()
77+
}
78+
79+
@Test
80+
fun contentFrame_withFirstFrameRendered_shutterOpens() {
81+
val player = TestPlayer()
82+
composeTestRule.setContent { ContentFrame(player) { Box(Modifier.testTag("Shutter")) } }
83+
composeTestRule.onNodeWithTag("Shutter").assertExists()
84+
85+
player.renderFirstFrame(true)
86+
composeTestRule.waitForIdle()
87+
88+
composeTestRule.onNodeWithTag("Shutter").assertDoesNotExist()
89+
}
90+
91+
@Test
92+
fun contentFrame_withContentScaleCrop_contentSizeIncreasesAspectRatioMaintained() {
93+
val player = TestPlayer()
94+
player.videoSize = VideoSize(360, 480)
95+
val aspectRatio = player.videoSize.width.toFloat() / player.videoSize.height
96+
lateinit var contentScale: MutableState<ContentScale>
97+
composeTestRule.setContent {
98+
contentScale = remember { mutableStateOf(ContentScale.Fit) }
99+
ContentFrame(
100+
player,
101+
contentScale = contentScale.value,
102+
modifier = Modifier.testTag("ContentFrame"),
103+
)
104+
}
105+
val initialBounds = composeTestRule.onNodeWithTag("ContentFrame").getBoundsInRoot()
106+
val initialAspectRatio = initialBounds.width / initialBounds.height
107+
108+
contentScale.value = ContentScale.Crop
109+
composeTestRule.waitForIdle()
110+
111+
val croppedBounds = composeTestRule.onNodeWithTag("ContentFrame").getBoundsInRoot()
112+
val croppedAspectRatio = croppedBounds.width / croppedBounds.height
113+
114+
assertThat(croppedBounds).isNotEqualTo(initialBounds)
115+
assertThat(abs(initialAspectRatio - aspectRatio) / aspectRatio).isLessThan(0.01f)
116+
assertThat(abs(croppedAspectRatio - aspectRatio) / aspectRatio).isLessThan(0.01f)
117+
}
118+
}

0 commit comments

Comments
 (0)