Clear surface from previous player when assigning a new player

The surface must only be used by one player at a time. To ensure
that, we can keep a reference to the previously used player
and clear its surface reference before assigning to a new one.

Note that we do not need to clear the surface in onDispose
of a DisposableEffect because the lifecycle management of the
surface is moved to the Player and the Player takes care of
unregistering its surface reference as soon as the surface is
destroyed (which happens when the AndroidView element is no longer
is the Composable tree).

PiperOrigin-RevId: 745558414
(cherry picked from commit f9617e1f8dade2b957fefd7eaa69da6d5c158eb8)
This commit is contained in:
tonihei 2025-04-09 06:13:02 -07:00
parent f35c59b176
commit c0dd97e770
3 changed files with 75 additions and 23 deletions

View File

@ -65,6 +65,7 @@
* UI: * UI:
* Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and * Enable `PlayerSurface` to work with `ExoPlayer.setVideoEffects` and
`CompositionPlayer`. `CompositionPlayer`.
* Fix bug where `PlayerSurface` can't be recomposed with a new `Player`.
* Downloads: * Downloads:
* Add partial download support for progressive streams. Apps can prepare a * Add partial download support for progressive streams. Apps can prepare a
progressive stream with `DownloadHelper`, and request a progressive stream with `DownloadHelper`, and request a

View File

@ -16,12 +16,17 @@
package androidx.media3.ui.compose package androidx.media3.ui.compose
import android.content.Context
import android.view.SurfaceView import android.view.SurfaceView
import android.view.TextureView import android.view.TextureView
import android.view.View
import androidx.annotation.IntDef import androidx.annotation.IntDef
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player import androidx.media3.common.Player
@ -47,37 +52,53 @@ fun PlayerSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW, surfaceType: @SurfaceType Int = SURFACE_TYPE_SURFACE_VIEW,
) { ) {
// Player might change between compositions,
// we need long-lived surface-related lambdas to always use the latest value
val currentPlayer by rememberUpdatedState(player)
when (surfaceType) { when (surfaceType) {
SURFACE_TYPE_SURFACE_VIEW -> SURFACE_TYPE_SURFACE_VIEW ->
AndroidView( PlayerSurfaceInternal(
factory = { player,
SurfaceView(it).apply { modifier,
if (currentPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) createView = { SurfaceView(it) },
currentPlayer.setVideoSurfaceView(this) setViewOnPlayer = { player, view -> player.setVideoSurfaceView(view) },
} clearViewFromPlayer = { player, view -> player.clearVideoSurfaceView(view) },
},
onReset = {},
modifier = modifier,
) )
SURFACE_TYPE_TEXTURE_VIEW -> SURFACE_TYPE_TEXTURE_VIEW ->
AndroidView( PlayerSurfaceInternal(
factory = { player,
TextureView(it).apply { modifier,
if (currentPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) createView = { TextureView(it) },
currentPlayer.setVideoTextureView(this) setViewOnPlayer = { player, view -> player.setVideoTextureView(view) },
} clearViewFromPlayer = { player, view -> player.clearVideoTextureView(view) },
},
onReset = {},
modifier = modifier,
) )
else -> throw IllegalArgumentException("Unrecognized surface type: $surfaceType") else -> throw IllegalArgumentException("Unrecognized surface type: $surfaceType")
} }
} }
@Composable
private fun <T : View> PlayerSurfaceInternal(
player: Player,
modifier: Modifier,
createView: (Context) -> T,
setViewOnPlayer: (Player, T) -> Unit,
clearViewFromPlayer: (Player, T) -> Unit,
) {
var view by remember { mutableStateOf<T?>(null) }
var registeredPlayer by remember { mutableStateOf<Player?>(null) }
AndroidView(factory = { createView(it).apply { view = this } }, onReset = {}, modifier = modifier)
view?.let { view ->
LaunchedEffect(view, player) {
registeredPlayer?.let { previousPlayer ->
if (previousPlayer.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE))
clearViewFromPlayer(previousPlayer, view)
registeredPlayer = null
}
if (player.isCommandAvailable(Player.COMMAND_SET_VIDEO_SURFACE)) {
setViewOnPlayer(player, view)
registeredPlayer = player
}
}
}
}
/** /**
* The type of surface used for media playbacks. One of [SURFACE_TYPE_SURFACE_VIEW] or * The type of surface used for media playbacks. One of [SURFACE_TYPE_SURFACE_VIEW] or
* [SURFACE_TYPE_TEXTURE_VIEW]. * [SURFACE_TYPE_TEXTURE_VIEW].

View File

@ -21,6 +21,7 @@ import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.ui.compose.utils.TestPlayer import androidx.media3.ui.compose.utils.TestPlayer
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@ -28,6 +29,9 @@ import com.google.common.truth.Truth.assertThat
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.inOrder
import org.mockito.Mockito.spy
/** Unit test for [PlayerSurface]. */ /** Unit test for [PlayerSurface]. */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -87,4 +91,30 @@ class PlayerSurfaceTest {
assertThat(player.videoOutput).isInstanceOf(SurfaceView::class.java) assertThat(player.videoOutput).isInstanceOf(SurfaceView::class.java)
} }
@Test
fun playerSurface_withNewPlayer_unsetsSurfaceOnOldPlayerFirst() {
val player0 = TestPlayer()
val player1 = TestPlayer()
val spyPlayer0 = spy(ForwardingPlayer(player0))
val spyPlayer1 = spy(ForwardingPlayer(player1))
lateinit var playerIndex: MutableIntState
composeTestRule.setContent {
playerIndex = remember { mutableIntStateOf(0) }
PlayerSurface(
player = if (playerIndex.intValue == 0) spyPlayer0 else spyPlayer1,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
)
}
composeTestRule.waitForIdle()
playerIndex.intValue = 1
composeTestRule.waitForIdle()
assertThat(player0.videoOutput).isNull()
assertThat(player1.videoOutput).isNotNull()
val inOrder = inOrder(spyPlayer0, spyPlayer1)
inOrder.verify(spyPlayer0).clearVideoSurfaceView(any())
inOrder.verify(spyPlayer1).setVideoSurfaceView(any())
}
} }