Last active
February 1, 2025 15:50
-
-
Save vighnesh153/47d1ac9fc6470cf870d6097413435e70 to your computer and use it in GitHub Desktop.
Exoplayer in Jetpack Compose
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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