mirror of
https://github.com/androidx/media.git
synced 2025-05-11 09:39:52 +08:00
Changes
This commit is contained in:
parent
3bce3af1a3
commit
934f0c25b2
@ -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'
|
||||
|
@ -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,51 +34,87 @@ 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) })
|
||||
@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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
|
@ -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",
|
||||
)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user