Skip to content

Instantly share code, notes, and snippets.

@rubenquadros
Last active March 17, 2025 10:01
Show Gist options
  • Save rubenquadros/f2af69972984b13273edd01825c5695e to your computer and use it in GitHub Desktop.
Save rubenquadros/f2af69972984b13273edd01825c5695e to your computer and use it in GitHub Desktop.
Custom ExoPlayer controls overlay
@Composable
private fun VideoPlayer(modifier: Modifier = Modifier) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context)
.apply {
setSeekBackIncrementMs(PLAYER_SEEK_BACK_INCREMENT)
setSeekForwardIncrementMs(PLAYER_SEEK_FORWARD_INCREMENT)
}
.build()
.apply {
setMediaItem(
MediaItem.Builder()
.apply {
setUri(
"https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4"
)
setMediaMetadata(
MediaMetadata.Builder()
.setDisplayTitle("My Video")
.build()
)
}
.build()
)
prepare()
playWhenReady = true
}
}
var shouldShowControls by remember { mutableStateOf(false) }
var isPlaying by remember { mutableStateOf(exoPlayer.isPlaying) }
var totalDuration by remember { mutableStateOf(0L) }
var currentTime by remember { mutableStateOf(0L) }
var bufferedPercentage by remember { mutableStateOf(0) }
var playbackState by remember { mutableStateOf(exoPlayer.playbackState) }
Box(modifier = modifier) {
DisposableEffect(key1 = Unit) {
val listener =
object : Player.Listener {
override fun onEvents(
player: Player,
events: Player.Events
) {
super.onEvents(player, events)
totalDuration = player.duration.coerceAtLeast(0L)
currentTime = player.currentPosition.coerceAtLeast(0L)
bufferedPercentage = player.bufferedPercentage
isPlaying = player.isPlaying
playbackState = player.playbackState
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
AndroidView(
modifier =
Modifier.clickable {
shouldShowControls = shouldShowControls.not()
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
useController = false
layoutParams =
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
)
PlayerControls(
modifier = Modifier.fillMaxSize(),
isVisible = { shouldShowControls },
isPlaying = { isPlaying },
title = { exoPlayer.mediaMetadata.displayTitle.toString() },
playbackState = { playbackState },
onReplayClick = { exoPlayer.seekBack() },
onForwardClick = { exoPlayer.seekForward() },
onPauseToggle = {
when {
exoPlayer.isPlaying -> {
// pause the video
exoPlayer.pause()
}
exoPlayer.isPlaying.not() &&
playbackState == STATE_ENDED -> {
exoPlayer.seekTo(0)
exoPlayer.playWhenReady = true
}
else -> {
// play the video
// it's already paused
exoPlayer.play()
}
}
isPlaying = isPlaying.not()
},
totalDuration = { totalDuration },
currentTime = { currentTime },
bufferedPercentage = { bufferedPercentage },
onSeekChanged = { timeMs: Float ->
exoPlayer.seekTo(timeMs.toLong())
}
)
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
private fun PlayerControls(
modifier: Modifier = Modifier,
isVisible: () -> Boolean,
isPlaying: () -> Boolean,
title: () -> String,
onReplayClick: () -> Unit,
onForwardClick: () -> Unit,
onPauseToggle: () -> Unit,
totalDuration: () -> Long,
currentTime: () -> Long,
bufferedPercentage: () -> Int,
playbackState: () -> Int,
onSeekChanged: (timeMs: Float) -> Unit
) {
val visible = remember(isVisible()) { isVisible() }
AnimatedVisibility(
modifier = modifier,
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
Box(modifier = Modifier.background(Color.Black.copy(alpha = 0.6f))) {
TopControl(
modifier = Modifier.align(Alignment.TopStart).fillMaxWidth(),
title = title
)
CenterControls(
modifier = Modifier.align(Alignment.Center).fillMaxWidth(),
isPlaying = isPlaying,
onReplayClick = onReplayClick,
onForwardClick = onForwardClick,
onPauseToggle = onPauseToggle,
playbackState = playbackState
)
BottomControls(
modifier =
Modifier.align(Alignment.BottomCenter)
.fillMaxWidth()
.animateEnterExit(
enter =
slideInVertically(
initialOffsetY = { fullHeight: Int ->
fullHeight
}
),
exit =
slideOutVertically(
targetOffsetY = { fullHeight: Int ->
fullHeight
}
)
),
totalDuration = totalDuration,
currentTime = currentTime,
bufferedPercentage = bufferedPercentage,
onSeekChanged = onSeekChanged
)
}
}
}
@Composable
private fun TopControl(modifier: Modifier = Modifier, title: () -> String) {
val videoTitle = remember(title()) { title() }
Text(
modifier = modifier.padding(16.dp),
text = videoTitle,
style = MaterialTheme.typography.h6,
color = Purple200
)
}
@Composable
private fun CenterControls(
modifier: Modifier = Modifier,
isPlaying: () -> Boolean,
playbackState: () -> Int,
onReplayClick: () -> Unit,
onPauseToggle: () -> Unit,
onForwardClick: () -> Unit
) {
val isVideoPlaying = remember(isPlaying()) { isPlaying() }
val playerState = remember(playbackState()) { playbackState() }
Row(modifier = modifier, horizontalArrangement = Arrangement.SpaceEvenly) {
IconButton(modifier = Modifier.size(40.dp), onClick = onReplayClick) {
Image(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
painter = painterResource(id = R.drawable.ic_replay_5),
contentDescription = "Replay 5 seconds"
)
}
IconButton(modifier = Modifier.size(40.dp), onClick = onPauseToggle) {
Image(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
painter =
when {
isVideoPlaying -> {
painterResource(id = R.drawable.ic_pause)
}
isVideoPlaying.not() && playerState == STATE_ENDED -> {
painterResource(id = R.drawable.ic_replay)
}
else -> {
painterResource(id = R.drawable.ic_play)
}
},
contentDescription = "Play/Pause"
)
}
IconButton(modifier = Modifier.size(40.dp), onClick = onForwardClick) {
Image(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
painter = painterResource(id = R.drawable.ic_forward_10),
contentDescription = "Forward 10 seconds"
)
}
}
}
@Composable
private fun BottomControls(
modifier: Modifier = Modifier,
totalDuration: () -> Long,
currentTime: () -> Long,
bufferedPercentage: () -> Int,
onSeekChanged: (timeMs: Float) -> Unit
) {
val duration = remember(totalDuration()) { totalDuration() }
val videoTime = remember(currentTime()) { currentTime() }
val buffer = remember(bufferedPercentage()) { bufferedPercentage() }
Column(modifier = modifier.padding(bottom = 32.dp)) {
Box(modifier = Modifier.fillMaxWidth()) {
Slider(
value = buffer.toFloat(),
enabled = false,
onValueChange = { /*do nothing*/},
valueRange = 0f..100f,
colors =
SliderDefaults.colors(
disabledThumbColor = Color.Transparent,
disabledActiveTrackColor = Color.Gray
)
)
Slider(
modifier = Modifier.fillMaxWidth(),
value = videoTime.toFloat(),
onValueChange = onSeekChanged,
valueRange = 0f..duration.toFloat(),
colors =
SliderDefaults.colors(
thumbColor = Purple200,
activeTickColor = Purple200
)
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = duration.formatMinSec(),
color = Purple200
)
IconButton(
modifier = Modifier.padding(horizontal = 16.dp),
onClick = {}
) {
Image(
contentScale = ContentScale.Crop,
painter = painterResource(id = R.drawable.ic_fullscreen),
contentDescription = "Enter/Exit fullscreen"
)
}
}
}
}
fun Long.formatMinSec(): String {
return if (this == 0L) {
"..."
} else {
String.format(
"%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(this),
TimeUnit.MILLISECONDS.toSeconds(this) -
TimeUnit.MINUTES.toSeconds(
TimeUnit.MILLISECONDS.toMinutes(this)
)
)
}
}
private const val PLAYER_SEEK_BACK_INCREMENT = 5 * 1000L // 5 seconds
private const val PLAYER_SEEK_FORWARD_INCREMENT = 10 * 1000L // 10 seconds
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment