Skip to content

Instantly share code, notes, and snippets.

@vighnesh153
Last active February 26, 2023 12:34
Show Gist options
  • Save vighnesh153/47d1ac9fc6470cf870d6097413435e70 to your computer and use it in GitHub Desktop.
Save vighnesh153/47d1ac9fc6470cf870d6097413435e70 to your computer and use it in GitHub Desktop.
Exoplayer in Jetpack Compose
package vighnesh153.androidx.exo_player_prototype
import android.net.Uri
import android.os.Build
import android.util.Log
import android.view.ViewGroup
import android.view.accessibility.CaptioningManager.CaptionStyle
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowLeft
import androidx.compose.material.icons.filled.ArrowRight
import androidx.compose.material.icons.filled.ClosedCaption
import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft
import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.viewinterop.AndroidView
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.core.view.marginBottom
import com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT
import com.google.android.exoplayer2.C.SELECTION_FLAG_DEFAULT
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MediaItem.SubtitleConfiguration
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.CaptionStyleCompat
import com.google.android.exoplayer2.ui.StyledPlayerView
import com.google.android.exoplayer2.util.MimeTypes
import kotlinx.coroutines.delay
import kotlinx.coroutines.yield
const val videoLink =
"https://firebasestorage.googleapis.com/v0/b/careful-compass-368609.appspot.com/o/exo-player-media%2Frick-astley-never-gonna-give-you-up.mp4?alt=media"
const val subtitleLink = "https://firebasestorage.googleapis.com/v0/b/careful-compass-368609.appspot.com/o/exo-player-media%2FRick-Astley-Never-Gonna-Give-You-Up.srt?alt=media"
const val TAG = "VighneshVideoPlayer"
/**
* Customization: https://exoplayer.dev/customization.html
*
* - Network Stack (Load from server, local files, etc) : https://exoplayer.dev/network-stacks.html
* - Caching data loaded from the network!
* - Customizing server interactions (http, or some other protocol)
*
*/
/**
* Features
*
* - Move forward/backward on left:right press
*/
@RequiresApi(Build.VERSION_CODES.Q)
@Composable
fun VideoPlayer(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val mediaItem = MediaItem.Builder()
.setUri(videoLink)
.setMediaId("never-gonna-give-you-up")
.setTag("never-gonna-give-you-up")
.setMediaMetadata(
MediaMetadata.Builder()
.setDisplayTitle("Never gonna give you up")
.build()
)
.setSubtitleConfigurations(
listOf(
SubtitleConfiguration
.Builder(Uri.parse(subtitleLink))
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
.setLanguage("en")
.setSelectionFlags(SELECTION_FLAG_DEFAULT)
.build()
)
)
.build()
var videoTitle by remember { mutableStateOf(mediaItem.mediaMetadata.displayTitle) }
var visibleState by remember { mutableStateOf(true) }
var videoDuration by remember { mutableStateOf<Long?>(null) }
var currentPosition by remember { mutableStateOf<Long?>(null) }
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
this.setMediaItem(mediaItem)
this.prepare()
this.playWhenReady = true
this.addListener(
object : Player.Listener {
override fun onEvents(
player: Player,
events: Player.Events
) {
super.onEvents(player, events)
// hide title only when player duration is at least 200ms
if (player.currentPosition >= 200) {
visibleState = false
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
super.onMediaItemTransition(mediaItem, reason)
// everytime the media item changes, show the title
visibleState = true
videoTitle = mediaItem?.mediaMetadata?.displayTitle.toString()
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
if (playbackState == ExoPlayer.STATE_READY && videoDuration == null) {
videoDuration = contentDuration
}
}
}
)
}
}
// TODO: Optimize this so that this runs only when video is playing
LaunchedEffect(Unit) {
while (true) {
yield()
delay(300)
currentPosition = exoPlayer.currentPosition
}
}
Box(modifier = modifier) {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
val (title, videoPlayer) = this.createRefs()
// video title
AnimatedVisibility(
visible = visibleState,
modifier = Modifier.constrainAs(title) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
) {
Text(
text = videoTitle.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.wrapContentHeight()
)
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
}
// Player view
AndroidView(
modifier = Modifier
.testTag("VideoPlayer")
.constrainAs(videoPlayer) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
factory = {
StyledPlayerView(context).apply {
player = exoPlayer
// useController = false
subtitleView?.setStyle(
CaptionStyleCompat(
android.graphics.Color.WHITE,
android.graphics.Color.RED,
android.graphics.Color.BLUE,
CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW,
android.graphics.Color.TRANSPARENT,
null
)
)
subtitleView?.setBottomPaddingFraction(0.8f)
// subtitleView?.setLeftTopRightBottom(20, 20, 0, 0)
subtitleView
layoutParams = FrameLayout
.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
}
)
}
// VideoPlayerOverlay(
// totalDuration = videoDuration ?: 0,
// currentPosition = currentPosition ?: 0,
// exoPlayer = exoPlayer,
// )
}
}
@Composable
fun VideoPlayerOverlay(
totalDuration: Long,
currentPosition: Long,
exoPlayer: ExoPlayer,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
PlayPauseButtons(
isPaused = !exoPlayer.isPlaying,
pause = { exoPlayer.pause() },
resume = { exoPlayer.play() },
seekForward = { exoPlayer.seekTo(currentPosition + it) },
seekBackward = { exoPlayer.seekTo(currentPosition - it) },
)
VideoOptions()
ProgressBar(
totalDuration = totalDuration,
currentPosition = currentPosition,
exoPlayer = exoPlayer,
modifier = Modifier,
)
DurationBox(
totalDuration = totalDuration,
currentPosition = currentPosition,
modifier = Modifier,
)
}
}
@Composable
fun BoxScope.PlayPauseButtons(
isPaused: Boolean,
pause: () -> Unit,
resume: () -> Unit,
seekForward: (millis: Long) -> Unit,
seekBackward: (millis: Long) -> Unit,
modifier: Modifier = Modifier,
) {
val seekAmount = 5000L
Row(
horizontalArrangement = Arrangement.spacedBy(20.dp),
modifier = modifier.align(Alignment.Center)
) {
val myIcon: @Composable (imageVector: ImageVector, size: Dp) -> Unit =
@Composable { imageVector, size ->
Icon(
imageVector = imageVector,
tint = Color.White,
modifier = Modifier.size(50.dp),
contentDescription = "",
)
}
val modifier = @Composable {
Modifier
.outlineOnFocus(
outlineColor = Color.White,
outlineShape = CircleShape,
outlineWidth = 2.dp,
outlineOffset = 4.dp,
)
.focusable()
}
IconButton(
onClick = { seekBackward(seekAmount) },
modifier = modifier(),
) {
myIcon(
imageVector = Icons.Default.KeyboardDoubleArrowLeft,
size = 60.dp,
)
}
IconButton(
onClick = {
if (isPaused) {
resume()
} else {
pause()
}
},
modifier = modifier(),
) {
myIcon(
imageVector = if (isPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
size = 50.dp,
)
}
IconButton(
onClick = { seekForward(seekAmount) },
modifier = modifier(),
) {
myIcon(
imageVector = Icons.Default.KeyboardDoubleArrowRight,
size = 60.dp,
)
}
}
}
@Composable
fun BoxScope.VideoOptions(modifier: Modifier = Modifier) {
var showMenu by remember { mutableStateOf(false) }
var menuHeading by remember { mutableStateOf("Heading") }
var menuOptions by remember { mutableStateOf(listOf<String>("Option 1", "Option 2", "Option 3")) }
Box(
modifier = Modifier
.fillMaxSize()
.onFocusChanged { showMenu = it.hasFocus }
) {
if (showMenu) {
// Option Menu
Box(
modifier = Modifier
.width(200.dp)
.align(Alignment.BottomEnd)
.padding(end = 20.dp, bottom = 175.dp)
.background(Color.Gray)
) {
Column(modifier = Modifier) {
Box(modifier = Modifier.fillMaxWidth()) {
Text(
text = menuHeading,
textAlign = TextAlign.Center,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
)
}
Divider()
menuOptions.forEach { option ->
var isFocused by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxWidth()
.background(if (isFocused) Color.White else Color.Transparent)
.onFocusChanged { isFocused = it.isFocused }
.focusable()
) {
Text(
text = option,
textAlign = TextAlign.Center,
color = if (isFocused) Color.Black else Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
)
}
}
}
}
}
Row(
modifier = modifier
.align(Alignment.BottomEnd)
.padding(bottom = 110.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
val option: @Composable (imageVector: ImageVector, heading: String, options: List<String>) -> Unit =
@Composable { imageVector, heading, options ->
var isFocused by remember { mutableStateOf(false) }
LaunchedEffect(isFocused) {
if (isFocused) {
menuHeading = heading
menuOptions = options
}
}
IconButton(
onClick = { /*TODO*/ },
modifier = Modifier
.outlineOnFocus(
outlineWidth = 2.dp,
outlineColor = Color.White,
outlineShape = CircleShape,
outlineOffset = 4.dp,
)
.background(
color = if (isFocused) Color.White else Color.Gray,
shape = CircleShape,
)
.onFocusChanged { isFocused = it.isFocused }
) {
Icon(
imageVector = imageVector,
contentDescription = "",
tint = if (isFocused) Color.Gray else Color.White,
)
}
}
option(
imageVector = Icons.Default.ClosedCaption,
heading = "Subtitles",
options = listOf("Disabled", "English", "French"),
)
option(
imageVector = Icons.Default.MusicNote,
heading = "Audio",
options = listOf("English", "French", "German"),
)
option(
imageVector = Icons.Default.Settings,
heading = "Settings",
options = listOf("Quality", "Speed", "Closed Captions Style"),
)
}
}
}
@Composable
fun BoxScope.ProgressBar(
totalDuration: Long,
currentPosition: Long,
exoPlayer: ExoPlayer,
modifier: Modifier = Modifier,
) {
if (totalDuration == 0L) {
return
}
val lineHeight = 4.dp
val seekAmount = 5000L
var size by remember { mutableStateOf(Size.Zero) }
val progressFraction = currentPosition.toFloat() / totalDuration.toFloat()
var isHandleFocused by remember { mutableStateOf(false) }
Box(
modifier = modifier
.padding(bottom = 60.dp)
.align(Alignment.BottomStart)
.onGloballyPositioned {
size = it.size.toSize()
}
.fillMaxWidth()
.heightIn(30.dp)
) {
// Full width line
Box(
modifier = Modifier
.fillMaxWidth()
.height(lineHeight)
.background(Color.Gray.copy(alpha = 0.8f))
.align(Alignment.Center)
)
// Progress line
Box(
modifier = Modifier
.fillMaxWidth(progressFraction)
.height(lineHeight)
.background(Color.White)
.align(Alignment.CenterStart)
)
// Handle
LocalDensity.current.apply {
val generateXOffsetWithProgressFraction: (progressFraction: Float) -> Dp = { progressFraction ->
(size.width * progressFraction).toDp() - 4.dp
}
val actualXOffset = (size.width * progressFraction).toDp() - 4.dp
var focusedXOffset by remember { mutableStateOf(actualXOffset) }
val xOffset = if (isHandleFocused) focusedXOffset else actualXOffset
var focusedProgressFraction by remember { mutableStateOf(progressFraction) }
var focusedCurrentPosition by remember { mutableStateOf(currentPosition) }
LaunchedEffect(isHandleFocused) {
focusedXOffset = actualXOffset
focusedCurrentPosition = currentPosition
}
val generateProgressFractionWithPositionOffset: (offset: Long) -> Float = { offset ->
(focusedCurrentPosition + offset).toFloat() / totalDuration.toFloat()
}
// Preview
if (isHandleFocused) {
Box(
modifier = Modifier
.size(120.dp, 80.dp)
.offset(xOffset - 50.dp, (-55).dp)
.background(Color.Red)
)
}
Box(
modifier = Modifier
.offset(xOffset, 0.dp)
.outlineOnFocus(
outlineColor = Color.White,
outlineShape = CircleShape,
outlineWidth = 2.dp,
outlineOffset = 4.dp,
)
.onKeyEvent {
if (it
.isTypeKeyDown()
.not()
) {
return@onKeyEvent KeyEventPropagation.ContinuePropagation
}
if (it.isDPadCenterPress()) {
exoPlayer.seekTo(focusedCurrentPosition)
}
if (it.isLeftPress()) {
focusedCurrentPosition -= seekAmount
focusedProgressFraction =
generateProgressFractionWithPositionOffset(-seekAmount)
focusedXOffset =
generateXOffsetWithProgressFraction(focusedProgressFraction)
return@onKeyEvent KeyEventPropagation.StopPropagation
}
if (it.isRightPress()) {
focusedCurrentPosition += seekAmount
focusedProgressFraction =
generateProgressFractionWithPositionOffset(seekAmount)
focusedXOffset =
generateXOffsetWithProgressFraction(focusedProgressFraction)
return@onKeyEvent KeyEventPropagation.StopPropagation
}
KeyEventPropagation.ContinuePropagation
}
.size(if (isHandleFocused) 15.dp else 12.dp)
.background(Color.White, CircleShape)
.onFocusChanged { isHandleFocused = it.isFocused }
.align(Alignment.CenterStart)
.focusable()
)
}
}
}
@Composable
fun Modifier.outlineOnFocus(
outlineWidth: Dp,
outlineColor: Color,
outlineOffset: Dp,
outlineShape: Shape,
): Modifier {
var isFocused by remember { mutableStateOf(false) }
return border(
width = if (isFocused) outlineWidth else 0.dp,
color = if (isFocused) outlineColor else Color.Transparent,
shape = outlineShape
)
.padding(outlineOffset)
.onFocusChanged { isFocused = it.isFocused }
}
@Composable
fun BoxScope.DurationBox(
totalDuration: Long,
currentPosition: Long,
modifier: Modifier = Modifier,
) {
val currentTime = convertMillisToReadableTime(currentPosition)
val totalTime = convertMillisToReadableTime(totalDuration)
Box(
modifier = modifier
.align(Alignment.BottomStart)
.padding(20.dp)
) {
Text(
text = "$currentTime · $totalTime",
color = Color.White,
style = TextStyle(
color = Color.White,
shadow = Shadow(
color = Color.Black,
offset = Offset(5.0f, 10.0f),
blurRadius = 3f,
)
)
)
}
}
fun convertMillisToReadableTime(millis: Long): ReadableTime {
var availableSeconds = millis / 1000;
val seconds = availableSeconds % 60
availableSeconds -= seconds
val minutes = availableSeconds / 60
availableSeconds -= minutes * 60
val hours = availableSeconds / (60 * 60)
return ReadableTime(
seconds = seconds,
minutes = minutes,
hours = hours
)
}
class ReadableTime(val seconds: Long, val minutes: Long, val hours: Long) {
override fun toString(): String {
val time = listOf(hours, minutes, seconds)
return time
.joinToString(":") { it.toString().padStart(2, '0') }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment