Black screen when ExoPlayer is used inside LazyColumn > LazyRow in Jetpack Compose
Version
Media3 main branch
More version details
When using ExoPlayer in Jetpack Compose within a LazyColumn that contains a LazyRow, the player sometimes renders as a black screen instead of displaying the video.
This issue appears only when the player is deeply nested within scrollable composables like LazyColumn > LazyRow. The audio plays, but the video surface is blank.
Devices that reproduce the issue
Using next layouts in demo-compose app (AndroidMedia)
LazyColumn(
modifier = Modifier.padding(top = 245.dp),
) {
// Row with videos
item {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp),
) {
items(videos.size) { index ->
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(videos[index]))
prepare()
playWhenReady = true
repeatMode = Player.REPEAT_MODE_ONE
}
}
PlayerSurface(
player = exoPlayer,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = Modifier
.width(140.dp)
.height(245.dp)
.padding(8.dp),
)
}
}
}
// Add dummy views to fill the column
items(30) { index ->
Text(
text = "Dummy item #$index",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
}
Scrolling up/down .. in some point a black screen appear. Please see video
https://github.com/user-attachments/assets/4e3b9e4c-38f4-481c-b369-f62b0b75f437
Devices reproduce the issue
Samsung S21 Emulator
Reproducible in the demo app?
Yes
Reproduction steps
- Open compose demo app
- Set layout share in this ticket
- Scroll up/down and horizontally for a while
- Observer a black screen appear.
Expected result
Player should work without getting black screen
Actual result
Player show black screen after several scrolls
Media
No DRM (use same videos as demo app)
Bug Report
- [x] You will email the zip file produced by
adb bugreportto [email protected] after filing this issue.
I tried to release the player instance
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
Problem is still present
@asos-luisalcantara, you are creating a player PER video item, it seems, which is very expensive. How big is your videos array? And how come all your video boxes show the same cat video if the index in the videos array should be changing?
I'm using the same video URL and have implemented caching, but I'm still getting the same result. I checked the ExoPlayer logs—the player is loading correctly, but the surface remains black.
I've noticed significant memory usage, and I suspect that PlayerSurface might be the root cause. Internally, it's using AndroidView, which calls createView on each recomposition—potentially leading to repeated view allocations and surface creation.
Also I am guessing that we need to release the player when view is dispose?
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
Could we have a memory leak on
@Composable
private fun <T : View> PlayerSurfaceInternal(
player: Player,
modifier: Modifier,
createView: (Context) -> T,
onReset: ((T) -> Unit)? = null,
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)
}, onReset = {
if (onReset != null) {
onReset(it)
}
view = null // I have updated code to reset the view
} , update = {
view = it // and when update is calling setting it again
}, modifier = modifier)
So issue is when PlayerSurface is in a list..??
@oceanjules My video array is just 5 items .. No should not be change I am using just the same video.
@asos-luisalcantara your modifications make more sense to me than what's in the release. Wonder what might happen if you disable reuse entirely? (Not performant enough, I guess, but does it show a black screen?)
I've had similar issues (blank screen) with this component, when app came back to the foreground, no LazyColumn or ReusableComposition in my case, but in a dialog. May be related - apparently there are other cases where the component fails in this manner.
@bubenheimer I found that the issue was caused by the remember block being called multiple times.
LazyRow {
items(
items = videos,
key = { url ->
url
}
){
}
The player is inflated several times causing OOM.
val player = remember(url) {
//Take player from a pool
}
I think AndroidView I think should be updated to take into account when is within a list.
Setting the surface view on update and resetting it on reset as a shared above. Or at least have an example in Exoplayer demo app about it.
Using a pool of player helps to prevent memory exception issues.
@oceanjules – would it be okay if I created a PR to include an example where the player is used within a list? Or is there another way we could include this in the demo?
@asos-luisalcantara @oceanjules The main problem I see with the current implementation here is onReset = {}. This will trigger reuse in a ReusableComposition, but the surrounding code does not support reuse, so things would break. It should say onReset = null, or don't specify it, or actually support reuse, but that's more work.
@asos-luisalcantara I'd hope that your PR would address this.
ReusableComposition would occur in LazyColumn/LazyRow/etc., but also in paging, and other, user-controlled contexts.
https://github.com/androidx/media/blob/52628e99ea69f86007da5faed9459f5337f4dbc4/libraries/ui_compose/src/main/java/androidx/media3/ui/compose/PlayerSurface.kt#L89
Do the videos work if you just place them inside of a lazycolumn (so that the videos are not nested) in order to see if they display correctly?
After some testing of different onReset values, I do not actually observe noticeable difference in behaviour or performance. Perhaps my setup is not ideal, I'm using a pool of 3 players
- We already have
LaunchedEffectthat handles cleanup. It manages the association between the Player and the SurfaceView/TextureView. When player changes or becomes null or when the view instance itself changes, we make sure thatclearVideoViewis called on the previously registered player. This is the most important cleanup step for the Player at least. - AndroidView's onReset is for situations where you need to perform specific cleanup on the View instance. For SurfaceView/TextureView = detaching the Player (which is already handled). What do you achieve by setting the view to null?
| onReset | |
|---|---|
| {} | Override any AndroidView default reset logic it applies to the view instance when it's being removed (which is minimal, mostly related to clearing listeners it might have attached itself). For SurfaceView and TextureView, there isn't much for AndroidView to "reset" that isn't already covered by the Player's interaction with the surface. |
| null | Let AndroidView to do its default action in its reset phase, which is also effectively nothing in terms of specific cleanup on the View instance that would affect the Player |
| {view=null} | The view would be nulled when the AndroidView is being disposed, but at this point the PlayerSurfaceInternal Composable itself is also being disposed (and the view state variable would be set to null). The LaunchedEffect would already be cancelling or would have completed its cleanup related to player becoming null. |
I understand that the docs say
- "When
onResetis specified,Viewinstances MAY be reused" - "This callback should prepare the view for general reuse"
- "Views are eligible for reuse if AndroidView is given a non-null
onResetcallback"
However, after extensive logging and stress testing, I cannot see how opting OUT of view reuse helps the app (even though theoretically I agree that opting out and documenting it is safer than opting in and trying to sort out the mess). I see the black frames with all sorts of onReset values, making me think that the bottleneck is in the large number of players/limited resources like decoders. Or maybe if the player is stopped or its surface is cleared when an item scrolls out of view, it needs to be re-prepared or re-buffered when it comes back. Maybe given that setting a surface on a player and the player rendering to it are asynchronous operations, the delay is too noticeable. Maybe setMediaItem() and prepare() haven't completed etc etc.
Realistically, there should be as good of an app architecture as possible to ensure a smooth transition. Maintaining a player pool, presetting a media item, controlling playWhenReady for players who have views out of scope, using a placeholder or thumbnail to cover the black frame and so on.
The problem to this approach is more of a human-hours bottleneck: the current compose demo app is targetting the same features that PlayerView supports - long-form content with minimal center controls, seekabe progress bar, ads markers, settings menu, fullscreen, track selection, subtitles.
What you are describing is another initiative that is happening in parallel over at https://github.com/androidx/media/tree/release/demos/shortform - unfortunately it's in Java and xml layouts, but you can see the difficulty of building a tiktok-style app with short videos that benefit from preloading, plus a PlayerPool -- you can see that it still assumes one video per window and yet the complexity is growing. Whereas you are building something similar to Pinterest grid and the issues that come with which video is currently in focus, whether to play multiple videos at the same time, how many videos are on screen at any one point vs the size of the PlayerPool etc.
If you can provide a clear before-and-after with changing the onReset callback (whether through a decrease in logs or even just a more visually pleasing user experience), I would be more convinced.
Leaving my code below for playing around. Click to expand
@Composable
fun ComposeDemoApp(modifier: Modifier = Modifier) {
val context = LocalContext.current
var playerPool by remember { mutableStateOf<List<Player?>>(emptyList()) }
val lazyListState = rememberLazyListState()
var activePlayerIndex by remember { mutableStateOf<Int?>(null) }
LifecycleStartEffect(Unit) {
val newPlayers = List(3) { initializePlayer(context) }
playerPool = newPlayers
onStopOrDispose {
newPlayers.forEach { it.release() }
playerPool = emptyList()
activePlayerIndex = null
}
}
LaunchedEffect(lazyListState, playerPool) {
if (playerPool.isEmpty()) return@LaunchedEffect
snapshotFlow { Pair(lazyListState.layoutInfo, lazyListState.firstVisibleItemScrollOffset) }
.map { (layoutInfo, _) ->
// Find the item closest to the center of the viewport
layoutInfo.visibleItemsInfo
.minByOrNull {
val itemCenter = it.offset + it.size / 2
val viewportCenter = layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset / 2
kotlin.math.abs(itemCenter - viewportCenter)
}
?.index
}
.filterNotNull()
.distinctUntilChanged()
.collect { mostVisibleItemIndex ->
val playerToActivateIndex = mostVisibleItemIndex % playerPool.size
activePlayerIndex = playerToActivateIndex
playerPool.forEachIndexed { poolIndex, playerInstance ->
playerInstance?.playWhenReady = (poolIndex == playerToActivateIndex)
}
}
}
if (playerPool.isNotEmpty()) {
LazyColumn(state = lazyListState) {
items(videos.size) { videoIndex ->
val playerIndexInPool = videoIndex % playerPool.size
val selectedPlayer = playerPool.getOrNull(playerIndexInPool)
selectedPlayer?.let { player ->
LaunchedEffect(player, videos[videoIndex]) {
player.setMediaItem(MediaItem.fromUri(videos[videoIndex]))
player.prepare()
player.repeatMode = Player.REPEAT_MODE_ONE
player.playWhenReady = (playerIndexInPool == activePlayerIndex)
}
PlayerSurface(
player = player,
surfaceType = SURFACE_TYPE_SURFACE_VIEW,
modifier = Modifier.width(400.dp).height(712.dp).padding(8.dp),
)
DisposableEffect(player) {
onDispose {
if (playerIndexInPool != activePlayerIndex) {
player.playWhenReady = false
}
}
}
}
}
}
}
}
private fun initializePlayer(context: Context): Player =
ExoPlayer.Builder(context).build().apply { prepare() }
Is there a recommended limit on how many players should be utilized in a setup like this?
@oceanjules using non-null onReset means that a View instance previously created by factory will (or may) be reused when the AndroidView component usage occurs within a ReusableComposition, when the composition is reused. When onReset is null, then the View instance is instead recreated via factory when the composition is reused.
The code sets the remembered view state as a side effect of calling factory. Slots (a.k.a. remember) are cleared prior to composition being reused. This means that the view state will always be null upon reuse and all access to the View object is lost.
Using non-null onReset here is a bug. It also provides no benefit in this scenario in the first place.
There is various Compose API documentation available around reusable compositions that describes its mechanisms, to help shed more light on the behavior I described above, but it's not exactly easy reading.
(Clarification: I am referring to the project's code here, not the code changes by @asos-luisalcantara, those may be reasonable.)
Thanks for pointing out the issue again. We were able to reproduce it and found a way to make PlayerSurface reusable inside these lazy containers. Updating the local variable in update as done in https://github.com/androidx/media/issues/2493#issuecomment-2934070582 is a key change, but that still left some issue with surface reuse on multiple players.
memory leak
@asos-luisalcantara Just to clarify - I don't think the view instances can leak because the remembered variable in the composable only stays assigned for as long as the composable is used inside your composition. Managing players in a LazyColumn is a slightly different topic, and the main issue was the surfaces were not correctly assigned to the player instances when the AndroidView was reused.
Is there a recommended limit on how many players should be utilized in a setup like this?
Is there a recommended limit on how many players should be utilized in a setup like this?
This is unrelated to the bug discussed here, but the general recommendation is to only use as many players as needed to play at the same time (e.g. one or two). It's best to treat the player instance as a sparse resource that is better to be reused and limited (The actual resources this applies to are the codecs used inside the player).
I ask due to the use of the number of players in the pool of players that are used in when recycling in order to prevent the black screen. I used the sample code that @oceanjules provided and could see the number of black screens vary when I changed the size of the pool.