I have a custom PlayerComponent based ExoPlayer. It makes the whole application not responsive when it is playing. I thought that the issue is about too frequent position state updates, but when I made update period longer (1 sec) pretty much nothing changed. I guess the issue might be due to too many events handled in pointerInput lambda. Also I was suggested to substitute Canvas with a Slider, but that doesn't suite my case because I nee to draw more complex timebar in the future. Can you help me out? What has the most sugnificant impact on performance?
@Composable internal fun PlayerControls( modifier: Modifier = Modifier, isPlaying: Boolean, position: Long, duration: Long, onPlay: () -> Unit, onPause: () -> Unit, onSeek: (Long) -> Unit, onSeekBack: () -> Unit, onSeekForward: () -> Unit, isFullScreen: Boolean = false, onFullscreen: ((Boolean) -> Unit)? = null, ) { val coroutineScope = rememberCoroutineScope() var isInteracting by rememberSaveable { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val controlsShowDuration = 3000 var notActiveTimer by rememberSaveable { mutableIntStateOf(0) } var controlsAlpha by rememberSaveable { mutableFloatStateOf(0f) } val animatedControlsAlpha by animateFloatAsState( targetValue = controlsAlpha, animationSpec = tween(300) ) LaunchedEffect(notActiveTimer) { when (notActiveTimer) { controlsShowDuration -> controlsAlpha = 1f 0 -> controlsAlpha = 0f } } LaunchedEffect(isInteracting) { if (isInteracting) { notActiveTimer = controlsShowDuration } else if (notActiveTimer > 0) { delay(controlsShowDuration.toLong()) notActiveTimer = 0 } } ConstraintLayout( modifier = modifier .clickable( interactionSource = interactionSource, indication = null ) {} .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Main) val change = event.changes.firstOrNull() ?: continue when { !change.previousPressed && change.pressed -> { // Pointer down isInteracting = true } change.pressed && change.positionChange() != Offset.Zero -> { // Dragging isInteracting = true } change.previousPressed && !change.pressed -> { // Pointer up or Cancelled isInteracting = false } } } } } ) { val (seekBack, seekForward, playBtn, seekBar) = createRefs() /* Left area to seek backward */ /* Right area to seek forward */ if (animatedControlsAlpha > 0) { /* Play/pause button */ if (duration >= 0) { Column( modifier = Modifier .fillMaxWidth() .graphicsLayer(alpha = animatedControlsAlpha) .constrainAs(seekBar) { bottom.linkTo(parent.bottom) start.linkTo(parent.start) end.linkTo(parent.end) } ) { /* Time presentation */ Box( modifier = Modifier .fillMaxWidth() .height(35.dp) .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Main) val change = event.changes.firstOrNull() ?: continue when { !change.previousPressed && change.pressed -> { // Pointer down isInteracting = true val newProgress = (change.position.x / size.width).coerceIn(0f..1f) val newPosition = (newProgress * duration).toLong() onSeek(newPosition) } change.pressed && change.positionChange() != Offset.Zero -> { // Dragging isInteracting = true val newProgress = (change.position.x / size.width).coerceIn(0f..1f) val newPosition = (newProgress * duration).toLong() onSeek(newPosition) } change.previousPressed && !change.pressed -> { // Pointer up or Cancelled isInteracting = false } } } } } ) { val primaryColor = MaterialTheme.colorScheme.primary Canvas( modifier = Modifier.matchParentSize() ) { val barHeight = 12f val width = size.width val height = size.height val progress = position.toFloat() / duration drawRoundRect( color = Color(200f, 200f, 200f, 0.7f), topLeft = Offset(0f, height / 2f - barHeight * 3f), size = Size(width, barHeight), cornerRadius = CornerRadius(barHeight) ) drawRoundRect( color = primaryColor, topLeft = Offset(0f, height / 2f - barHeight * 3f), size = Size(width * progress, barHeight), cornerRadius = CornerRadius(barHeight) ) drawCircle( color = primaryColor, radius = 1.3f * barHeight, center = Offset( width * progress, height / 2f - barHeight * 3f + barHeight / 2 ) ) } } } } } } }
UPD
Left the container and box with a canvas because they are primary suspects because they trigger state changes and throttle performance.
UPD 2
While I was inspecting the PlayerComponent I realized that the issue could be in recomposing AndroidView for video surface
Previously my PlayerComponent looked like this:
@OptIn(UnstableApi::class) @Composable fun PlayerComponent( modifier: Modifier = Modifier, player: Player, isFullScreen: Boolean = false, onFullscreen: ((Boolean) -> Unit)? = null, onRatioObtained: ((Float) -> Unit)? = null ) { val context = LocalContext.current var playerState by rememberSaveable { mutableIntStateOf(player.playbackState) } var isPlaying by rememberSaveable { mutableStateOf(player.isPlaying) } var duration by rememberSaveable { mutableLongStateOf(player.duration) } var position by rememberSaveable { mutableLongStateOf(player.currentPosition) } var savedPlayState by rememberSaveable { mutableStateOf(player.isPlaying) } var aspectRatio by rememberSaveable { mutableFloatStateOf(16f / 9) } Box( modifier = modifier.background(Color.Black), contentAlignment = Alignment.Center ) { AndroidView( modifier = Modifier.matchParentSize(), factory = { PlayerView(it).apply { useController = false this.player = player } } ) val seekRange = 5000L PlayerControls( modifier = Modifier.matchParentSize(), position = position, duration = duration, isPlaying = isPlaying, onPlay = player::play, onPause = player::pause, onSeekBack = { val newPosition = (player.currentPosition - seekRange).coerceAtLeast(0) player.seekTo(newPosition) }, onSeekForward = { val newPosition = (player.currentPosition + seekRange).coerceAtMost(player.duration - 1) player.seekTo(newPosition) }, onSeek = player::seekTo, isFullScreen = isFullScreen, onFullscreen = onFullscreen ) } LaunchedEffect(isPlaying) { val changePeriod = 250L while (isActive && isPlaying) { position = player.currentPosition val delayDuration = changePeriod - (position % changePeriod) delay(delayDuration) } } }
But then I substituted the AndroidView with my VideoSurface composable and passed () -> Player instead of just Player
@Composable internal fun VideoSurface( modifier: Modifier = Modifier, playerProvider: () -> Player ) { AndroidView( modifier = modifier, factory = { PlayerView(it).apply { useController = false this.player = playerProvider() } } ) }
The whole new setup is the following:
@OptIn(UnstableApi::class) @Composable fun PlayerComponent( modifier: Modifier = Modifier, playerProvider: () -> Player, isFullScreen: Boolean = false, onFullscreen: ((Boolean) -> Unit)? = null, onRatioObtained: ((Float) -> Unit)? = null ) { val context = LocalContext.current val player = retain { playerProvider() } var playerState by rememberSaveable { mutableIntStateOf(player.playbackState) } var isPlaying by rememberSaveable { mutableStateOf(player.isPlaying) } var duration by rememberSaveable { mutableLongStateOf(player.duration) } var position by rememberSaveable { mutableLongStateOf(player.currentPosition) } var savedPlayState by rememberSaveable { mutableStateOf(player.isPlaying) } var aspectRatio by rememberSaveable { mutableFloatStateOf(16f / 9) } Box( modifier = modifier.background(Color.Black), contentAlignment = Alignment.Center ) { VideoSurface( modifier = Modifier.matchParentSize(), playerProvider = playerProvider ) val seekRange = 5000L PlayerControls( modifier = Modifier.matchParentSize(), position = position, duration = duration, isPlaying = isPlaying, onPlay = player::play, onPause = player::pause, onSeekBack = { val newPosition = (player.currentPosition - seekRange).coerceAtLeast(0) player.seekTo(newPosition) }, onSeekForward = { val newPosition = (player.currentPosition + seekRange).coerceAtMost(player.duration - 1) player.seekTo(newPosition) }, onSeek = player::seekTo, isFullScreen = isFullScreen, onFullscreen = onFullscreen ) } LaunchedEffect(isPlaying) { val changePeriod = 250L while (isActive && isPlaying) { position = player.currentPosition val delayDuration = changePeriod - (position % changePeriod) delay(delayDuration) } } } @Composable internal fun PlayerControls( modifier: Modifier = Modifier, isPlaying: Boolean, position: Long, duration: Long, onPlay: () -> Unit, onPause: () -> Unit, onSeek: (Long) -> Unit, onSeekBack: () -> Unit, onSeekForward: () -> Unit, isFullScreen: Boolean = false, onFullscreen: ((Boolean) -> Unit)? = null, ) { val coroutineScope = rememberCoroutineScope() var isInteracting by rememberSaveable { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val controlsShowDuration = 3000 var notActiveTimer by rememberSaveable { mutableIntStateOf(0) } var controlsAlpha by rememberSaveable { mutableFloatStateOf(0f) } val animatedControlsAlpha by animateFloatAsState( targetValue = controlsAlpha, animationSpec = tween(300) ) LaunchedEffect(notActiveTimer) { when (notActiveTimer) { controlsShowDuration -> controlsAlpha = 1f 0 -> controlsAlpha = 0f } } LaunchedEffect(isInteracting) { if (isInteracting) { notActiveTimer = controlsShowDuration } else if (notActiveTimer > 0) { delay(controlsShowDuration.toLong()) notActiveTimer = 0 } } ConstraintLayout( modifier = modifier .clickable( interactionSource = interactionSource, indication = null ) {} .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Main) val change = event.changes.firstOrNull() ?: continue when { !change.previousPressed && change.pressed -> { // Pointer down isInteracting = true } change.pressed && change.positionChange() != Offset.Zero -> { // Dragging isInteracting = true } change.previousPressed && !change.pressed -> { // Pointer up or Cancelled isInteracting = false } } } } } ) { val (seekBack, seekForward, playBtn, seekBar) = createRefs() /* Left area to seek backward */ /* Right area to seek forward */ if (animatedControlsAlpha > 0) { /* Play/pause button */ if (duration >= 0) { Column( modifier = Modifier .fillMaxWidth() .graphicsLayer(alpha = animatedControlsAlpha) .constrainAs(seekBar) { bottom.linkTo(parent.bottom) start.linkTo(parent.start) end.linkTo(parent.end) } ) { /* Time presentation */ Box( modifier = Modifier .fillMaxWidth() .height(35.dp) .pointerInput(Unit) { awaitPointerEventScope { while (true) { val event = awaitPointerEvent(PointerEventPass.Main) val change = event.changes.firstOrNull() ?: continue when { !change.previousPressed && change.pressed -> { // Pointer down isInteracting = true val newProgress = (change.position.x / size.width).coerceIn(0f..1f) val newPosition = (newProgress * duration).toLong() onSeek(newPosition) } change.pressed && change.positionChange() != Offset.Zero -> { // Dragging isInteracting = true val newProgress = (change.position.x / size.width).coerceIn(0f..1f) val newPosition = (newProgress * duration).toLong() onSeek(newPosition) } change.previousPressed && !change.pressed -> { // Pointer up or Cancelled isInteracting = false } } } } } ) { val primaryColor = MaterialTheme.colorScheme.primary Canvas( modifier = Modifier.matchParentSize() ) { val barHeight = 12f val width = size.width val height = size.height val progress = position.toFloat() / duration drawRoundRect( color = Color(200f, 200f, 200f, 0.7f), topLeft = Offset(0f, height / 2f - barHeight * 3f), size = Size(width, barHeight), cornerRadius = CornerRadius(barHeight) ) drawRoundRect( color = primaryColor, topLeft = Offset(0f, height / 2f - barHeight * 3f), size = Size(width * progress, barHeight), cornerRadius = CornerRadius(barHeight) ) drawCircle( color = primaryColor, radius = 1.3f * barHeight, center = Offset( width * progress, height / 2f - barHeight * 3f + barHeight / 2 ) ) } } } } } } }
Usage example:
@Composable fun Demo() { val context = LocalContext.current PlayerComponent( modifier = Modifier .fillMaxWidth() .aspectRatio(16f/9), playerProvider = { ExoPlayer.Builder(context).build() }, onRatioObtained = {}, isFullScreen = false, onFullscreen = {} ) }
Although the "Application is not responding" message doesn't appear now, I'm still not 100% sure that there will be no performance issues in the future. Moreover, sometimes the play button and seekbar inside PlayerControls don't appear on each tap.