Skip to content

Black screen when ExoPlayer is used inside LazyColumn > LazyRow in Jetpack Compose #2493

Closed

Description

@asos-luisalcantara

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

bug_videos.mp4

Devices reproduce the issue

Samsung S21
Emulator

Reproducible in the demo app?

Yes

Reproduction steps

  1. Open compose demo app
  2. Set layout share in this ticket
  3. Scroll up/down and horizontally for a while
  4. 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

Activity

  1. asos-luisalcantara commented on Jun 1, 2025

    @asos-luisalcantara
    Author

    I tried to release the player instance

       DisposableEffect(Unit) {
                             onDispose {
                                            exoPlayer.release()
                                        }
       }
    

    Problem is still present

  2. oceanjules commented on Jun 2, 2025

    @oceanjules
    Contributor

    @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?

  3. asos-luisalcantara commented on Jun 3, 2025

    @asos-luisalcantara
    Author

    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()
            }
        }
    
  4. asos-luisalcantara commented on Jun 3, 2025

    @asos-luisalcantara
    Author

    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)
    
  5. asos-luisalcantara commented on Jun 3, 2025

    @asos-luisalcantara
    Author

    So issue is when PlayerSurface is in a list..??

  6. asos-luisalcantara commented on Jun 3, 2025

    @asos-luisalcantara
    Author

    @oceanjules My video array is just 5 items .. No should not be change I am using just the same video.

  7. bubenheimer commented on Jun 3, 2025

    @bubenheimer
    Contributor

    @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.

  8. asos-luisalcantara commented on Jun 5, 2025

    @asos-luisalcantara
    Author

    @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?

  9. bubenheimer commented on Jun 5, 2025

    @bubenheimer
    Contributor

    @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.

    AndroidView(factory = { createView(it).apply { view = this } }, onReset = {}, modifier = modifier)

  10. lostdawg3 commented on Jun 10, 2025

    @lostdawg3

    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?

  11. oceanjules commented on Jun 11, 2025

    @oceanjules
    Contributor

    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 LaunchedEffect that 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 that clearVideoView is 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 onReset is specified, View instances MAY be reused"
    • "This callback should prepare the view for general reuse"
    • "Views are eligible for reuse if AndroidView is given a non-null onReset callback"

    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() }
  12. lostdawg3 commented on Jun 11, 2025

    @lostdawg3

    Is there a recommended limit on how many players should be utilized in a setup like this?

  13. bubenheimer commented on Jun 12, 2025

    @bubenheimer
    Contributor

    @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.)

  14. tonihei commented on Jun 20, 2025

    @tonihei
    Collaborator

    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 #2493 (comment) 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.

  15. lostdawg3 commented on Jun 20, 2025

    @lostdawg3

    Is there a recommended limit on how many players should be utilized in a setup like this?

  16. tonihei commented on Jun 20, 2025

    @tonihei
    Collaborator

    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).

  17. lostdawg3 commented on Jun 20, 2025

    @lostdawg3

    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.

  18. locked and limited conversation to collaborators on Aug 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions