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 composeBom
implementation 'androidx.activity:activity-compose:1.9.0' 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.foundation:foundation'
implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material3:material3'
implementation 'androidx.compose.material:material-icons-extended' implementation 'androidx.compose.material:material-icons-extended'

View File

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

View File

@ -18,5 +18,7 @@ package androidx.media3.demo.compose.data
val videos = val videos =
listOf( listOf(
"https://html5demos.com/assets/dizzy.mp4", "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-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)
}
}