This commit is contained in:
Jolanda Verhoef 2024-12-13 12:18:58 +00:00
parent 3bce3af1a3
commit 934f0c25b2
5 changed files with 242 additions and 18 deletions

View File

@ -70,6 +70,7 @@ dependencies {
implementation composeBom
implementation 'androidx.activity:activity-compose:1.9.0'
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.7'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended'

View File

@ -15,13 +15,13 @@
*/
package androidx.media3.demo.compose
import android.content.Context
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -34,32 +34,66 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.LifecycleStartEffect
import androidx.media3.common.MediaItem
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.noRippleClickable
import androidx.media3.demo.compose.layout.scaledWithAspectRatio
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.state.rememberRenderingState
class MainActivity : ComponentActivity() {
private lateinit var player: Player
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
player = initializePlayer()
setContent {
MediaPlayerScreen(player = player, modifier = Modifier.fillMaxSize().navigationBarsPadding())
MyApp()
}
}
}
private fun initializePlayer(): Player {
return ExoPlayer.Builder(this).build().apply {
setMediaItems(videos.map { MediaItem.fromUri(it) })
prepare()
@Composable
fun MyApp(modifier: Modifier = Modifier) {
val context = LocalContext.current
var player by remember { mutableStateOf<Player?>(null) }
if (Build.VERSION.SDK_INT > 23) {
LifecycleStartEffect(Unit) {
player = initializePlayer(context)
onStopOrDispose { releasePlayer(player); player = null }
}
} else {
LifecycleResumeEffect(Unit) {
player = initializePlayer(context)
onPauseOrDispose { releasePlayer(player); player = null }
}
}
player?.let {
MediaPlayerScreen(
player = it,
modifier = modifier.fillMaxSize().navigationBarsPadding()
)
}
}
private fun initializePlayer(context: Context): Player =
ExoPlayer.Builder(context).build().apply {
setMediaItems(videos.map(MediaItem::fromUri))
prepare()
}
private fun releasePlayer(player: Player?) {
player?.let {
if (player.availableCommands.contains(Player.COMMAND_RELEASE)) {
player.release()
}
}
}
@ -67,18 +101,20 @@ class MainActivity : ComponentActivity() {
@Composable
private fun MediaPlayerScreen(player: Player, modifier: Modifier = Modifier) {
var showControls by remember { mutableStateOf(true) }
Box(modifier) {
val renderingState = rememberRenderingState(player)
val scaledModifier = modifier.scaledWithAspectRatio(ContentScale.Fit, renderingState.aspectRatio)
Box(scaledModifier) {
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier =
modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // to prevent the ripple from the tap
) {
showControls = !showControls
},
modifier = Modifier.noRippleClickable { showControls = !showControls },
)
if (!renderingState.renderedFirstFrame) {
// hide the surface that is being prepared behind a scrim
Box(scaledModifier.background(Color.Black))
}
if (showControls) {
MinimalControls(player, Modifier.align(Alignment.Center))
ExtraControls(

View File

@ -18,5 +18,7 @@ package androidx.media3.demo.compose.data
val videos =
listOf(
"https://html5demos.com/assets/dizzy.mp4",
"https://storage.googleapis.com/exoplayer-test-media-0/shortform_2.mp4",
"https://storage.googleapis.com/exoplayer-test-media-1/gen-3/screens/dash-vod-single-segment/video-vp9-360.webm",
"https://storage.googleapis.com/exoplayer-test-media-0/shortform_3.mp4",
)

View File

@ -0,0 +1,53 @@
package androidx.media3.demo.compose.layout
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@Composable
internal fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier {
return then(
clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null, // to prevent the ripple from the tap
) {
onClick()
}
)
}
@Composable
internal fun Modifier.scaledWithAspectRatio(
contentScale: ContentScale,
videoAspectRatio: Float,
): Modifier {
if (videoAspectRatio == 0f) {
// Video has not been decoded yet, let the component occupy incoming constraints
return Modifier
}
// TODO: decide if using aspectRatio is better than layout out the Composables ourselves
// ideally we would have aspectRatio( () -> videoAspectRatio ) to defer reads
return when (contentScale) {
ContentScale.FillWidth ->
then(
Modifier.fillMaxWidth().aspectRatio(videoAspectRatio, matchHeightConstraintsFirst = false)
)
ContentScale.FillHeight ->
then(
Modifier.fillMaxHeight().aspectRatio(videoAspectRatio, matchHeightConstraintsFirst = true)
)
ContentScale.Fit -> then(Modifier.aspectRatio(videoAspectRatio))
// TODO: figure out how to implement these
// ContentScale.Crop -> ...? // like RESIZE_MODE_ZOOM? How to measure "deformation"?
// ContentScale.Inside -> ...? // no resizeMode equivalent, need to test with a tiny vid
// ContentScale.FillBounds -> then(Modifier)? like None? or is None like Inside?
ContentScale.None -> then(Modifier)
else -> then(Modifier)
}
}

View File

@ -0,0 +1,132 @@
/*
* 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.ui.compose.state
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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
@UnstableApi
@Composable
fun rememberRenderingState(player: Player): RenderingState {
val renderingState = remember(player) { RenderingState(player) }
LaunchedEffect(player) { renderingState.observe() }
return renderingState
}
@UnstableApi
class RenderingState(private val player: Player) {
var aspectRatio by mutableFloatStateOf(getAspectRatio(player))
private set
var renderedFirstFrame by mutableStateOf(false)
private set
var onRenderedFirstFrame: () -> Unit = {}
var keepContentOnPlayerReset: Boolean = false
private var lastPeriodUidWithTracks: Any? = null
suspend fun observe(): Nothing =
player.listen { events ->
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
if (videoSize != VideoSize.UNKNOWN) {
aspectRatio = getAspectRatio(player)
}
}
if (events.contains(Player.EVENT_RENDERED_FIRST_FRAME)) {
renderedFirstFrame = true
onRenderedFirstFrame()
}
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
// PlayerView's combo of updateForCurrentTrackSelections and onTracksChanged
if (!suppressShutter(player)) {
resetRenderedFirstFrame(player)
}
}
}
private fun getAspectRatio(player: Player): Float {
val videoSize = player.videoSize
val width = videoSize.width
val height = videoSize.height
return if ((height == 0 || width == 0)) 0f
else (width * videoSize.pixelWidthHeightRatio) / height
}
private fun resetRenderedFirstFrame(player: Player) {
val hasTracks =
player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty
if (!keepContentOnPlayerReset && !hasTracks) {
renderedFirstFrame = false
return
}
if (hasTracks && !hasSelectedVideoTrack()) {
renderedFirstFrame = false
return
}
}
private fun suppressShutter(player: Player): Boolean {
// Suppress the update if transitioning to an unprepared period within the same window. This
// is necessary to avoid closing the shutter when such a transition occurs. See:
// https://github.com/google/ExoPlayer/issues/5507.
val timeline =
if (player.isCommandAvailable(Player.COMMAND_GET_TIMELINE)) player.currentTimeline
else Timeline.EMPTY
if (timeline.isEmpty) {
lastPeriodUidWithTracks = null
return false
} else {
val period = Timeline.Period()
if (player.isCommandAvailable(Player.COMMAND_GET_TRACKS) && !player.currentTracks.isEmpty) {
lastPeriodUidWithTracks = timeline.getPeriod(player.currentPeriodIndex, period, true).uid
} else {
if (lastPeriodUidWithTracks != null) {
val lastPeriodIndexWithTracks = timeline.getIndexOfPeriod(lastPeriodUidWithTracks!!)
if (lastPeriodIndexWithTracks != C.INDEX_UNSET) {
val lastWindowIndexWithTracks =
timeline.getPeriod(lastPeriodIndexWithTracks, period).windowIndex
if (player.currentMediaItemIndex == lastWindowIndexWithTracks) {
// We're in the same media item. Suppress the update.
return true
}
}
lastPeriodUidWithTracks = null
}
}
}
return false
}
private fun hasSelectedVideoTrack(): Boolean {
return player.isCommandAvailable(Player.COMMAND_GET_TRACKS) &&
player.currentTracks.isTypeSelected(C.TRACK_TYPE_VIDEO)
}
}