Skip to content

Instantly share code, notes, and snippets.

@edivad1999
Last active September 3, 2023 10:12
Show Gist options
  • Save edivad1999/c478425c8456bd3703313a5887e0063c to your computer and use it in GitHub Desktop.
Save edivad1999/c478425c8456bd3703313a5887e0063c to your computer and use it in GitHub Desktop.
@SuppressLint("OpaqueUnitKey")
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
fun VideoPlayer(uri: String, modifier: Modifier = Modifier) {
val customPlayer = rememberSaveable(saver = MyPlayer.saver) {
MyPlayer(uri)
}
Player(customPlayer = customPlayer, modifier = modifier)
PlayerDisposer(player = customPlayer)
}
@Composable
fun PlayerDisposer(player: Player) {
DisposableEffect(Unit) {
onDispose {
player.stop()
player.release()
}
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
internal fun Player(
customPlayer: MyPlayer, modifier: Modifier = Modifier
) {
val coroutine = rememberCoroutineScope()
val isPlaying by customPlayer.isPlayingFlow.collectAsState()
val isMuted by customPlayer.isMutedFlow.collectAsState(customPlayer.isMuted())
val currentVideoTime by customPlayer.currentTimeStamp.collectAsState(initial = VideoTime.default)
var showOverlay: Instant? by remember {
mutableStateOf(Instant.now())
}
var isFullScreen: Boolean by rememberSaveable() {
mutableStateOf(false)
}
LaunchedEffect(showOverlay) {
if (showOverlay != null) {
delay(3000)
showOverlay = null
}
}
ExoComponent(
modifier = modifier,
customPlayer = customPlayer,
isFullScreen = isFullScreen,
dismissFullScreen = {
showOverlay = Instant.now()
isFullScreen = false
}) {
OverlayCommands(isPlaying = isPlaying, onCenterClick = {
showOverlay = Instant.now()
if (showOverlay != null) coroutine.launch {
customPlayer.onPlayOrResume()
}
}, onLeftClick = {
showOverlay = Instant.now()
if (showOverlay != null && it) customPlayer.seekBack()
}, onRightClick = {
showOverlay = Instant.now()
if (showOverlay != null && it) customPlayer.seekForward()
})
Column(modifier = Modifier.align(Alignment.BottomEnd)) {
AnimatedVisibility(visible = showOverlay != null) {
Column {
BottomCommands(currentVideoTime = currentVideoTime, onValueChange = {
showOverlay = Instant.now()
customPlayer.seekToMillis(it.toLong())
}) {
MuteButton(isMuted = isMuted) {
showOverlay = Instant.now()
coroutine.launch {
customPlayer.muteOrUnMute()
}
}
Spacer(modifier = Modifier.width(8.dp))
FullScreenButton(isFullScreen = isFullScreen, onClick = {
isFullScreen = it
})
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@Composable
fun ExoComponent(
modifier: Modifier,
customPlayer: MyPlayer,
isFullScreen: Boolean,
dismissFullScreen: () -> Unit,
overlay: @Composable BoxScope.() -> Unit
) {
if (isFullScreen) {
Box(modifier.fillMaxSize())
Dialog(
onDismissRequest = dismissFullScreen, DialogProperties(usePlatformDefaultWidth = false)
) {
Surface {
Box(Modifier.fillMaxSize()) {
Exoplayer(customPlayer = customPlayer)
overlay()
}
}
}
} else {
Box(modifier.fillMaxSize()) {
Exoplayer(customPlayer = customPlayer)
overlay()
}
}
}
@Composable
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
fun Exoplayer(customPlayer: MyPlayer) {
val context = LocalContext.current
AndroidView(modifier = Modifier.fillMaxSize(), factory = {
PlayerView(context).apply {
hideController()
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
player = customPlayer
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
}
})
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomCommands(
currentVideoTime: VideoTime,
onValueChange: (Float) -> Unit,
endContent: @Composable RowScope.() -> Unit
) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
modifier = Modifier.padding(horizontal = 4.dp),
text = "${currentVideoTime.currentTime.formatToTime()}/${currentVideoTime.contentDuration.formatToTime()}",
style = MaterialTheme.typography.labelSmall
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
)
endContent()
}
Box(
modifier = Modifier
.fillMaxWidth()
) {
val interactionSource = remember {
MutableInteractionSource()
}
Slider(
interactionSource = interactionSource,
value = currentVideoTime.currentTime.toFloat().takeIf { it >= 0 } ?: 0f,
onValueChange = onValueChange,
valueRange = 0f..(currentVideoTime.contentDuration.toFloat().takeIf { it >= 0 } ?: 0f),
thumb = {
SliderDefaults.Thumb(
modifier = Modifier.padding(top = 2.5.dp),
interactionSource = interactionSource,
colors = SliderDefaults.colors(),
enabled = true, thumbSize = DpSize(15.dp, 15.dp)
)
}
)
}
}
fun Long.formatToTime(): String = String.format(
"%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(this),
TimeUnit.MILLISECONDS.toSeconds(this) -
TimeUnit.MINUTES.toSeconds(
TimeUnit.MILLISECONDS.toMinutes(this)
)
)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OverlayCommands(
isPlaying: Boolean,
onCenterClick: () -> Unit,
onLeftClick: (Boolean) -> Unit,
onRightClick: (Boolean) -> Unit
) {
val indication = null
val interaction = remember {
MutableInteractionSource()
}
var showIcon: Instant? by remember {
mutableStateOf(null)
}
LaunchedEffect(showIcon) {
if (showIcon != null) {
delay(1000)
showIcon = null
}
}
Row(modifier = Modifier.fillMaxSize()) {
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.combinedClickable(interactionSource = interaction,
indication = indication,
onDoubleClick = {
onLeftClick(true)
},
onClick = {
onLeftClick(false)
}),
)
Box(
Modifier
.weight(2f)
.fillMaxHeight()
.clickable(
interactionSource = interaction, indication = indication
) {
onCenterClick()
showIcon = Instant.now()
}) {
val iconModifier = Modifier
.align(Alignment.Center)
.size(48.dp)
val color = LocalContentColor.current.copy(0.75f)
if (showIcon != null) if (isPlaying) Icon(
Icons.Default.PlayArrow,
modifier = iconModifier,
contentDescription = "",
tint = color
) else Icon(
Icons.Default.Pause, modifier = iconModifier, contentDescription = "", tint = color
)
}
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.combinedClickable(interactionSource = interaction,
indication = indication,
onDoubleClick = {
onRightClick(true)
},
onClick = { onRightClick(false) }),
)
}
}
@Composable
fun MuteButton(isMuted: Boolean, onClick: () -> Unit) {
Surface(
shape = CircleShape,
modifier = Modifier.size(32.dp),
onClick = onClick,
color = Color.Transparent,
// contentColor = color
) {
Box(modifier = Modifier.fillMaxSize()) {
if (isMuted) Icon(
Icons.Default.VolumeMute,
contentDescription = "",
modifier = Modifier
.size(24.dp)
.align(Alignment.Center)
)
else Icon(
Icons.Default.VolumeUp,
contentDescription = "",
modifier = Modifier
.size(24.dp)
.align(Alignment.Center)
)
}
}
}
@Composable
fun FullScreenButton(isFullScreen: Boolean, onClick: (Boolean) -> Unit) {
Surface(
shape = CircleShape,
modifier = Modifier.size(32.dp),
onClick = { onClick(!isFullScreen) },
color = Color.Transparent,
// contentColor = color
) {
Box(modifier = Modifier.fillMaxSize()) {
if (isFullScreen) Icon(
Icons.Default.FullscreenExit,
contentDescription = "",
modifier = Modifier
.size(24.dp)
.align(Alignment.Center)
)
else Icon(
Icons.Default.Fullscreen,
contentDescription = "",
modifier = Modifier
.size(24.dp)
.align(Alignment.Center)
)
}
}
}
data class VideoTime(val contentDuration: Long, val currentTime: Long, val bufferTime: Long) {
companion object {
val default get() = VideoTime(0, 0, 0)
}
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
class MyPlayer(
val uri: String,
val context: Context = getKoin().get(),
exoPlayer: ExoPlayer = ExoPlayer.Builder(context).setUseLazyPreparation(false).build()
) : KoinComponent, ExoPlayer by exoPlayer {
init {
val cache by inject<CacheDataSource.Factory>()
val source = ProgressiveMediaSource.Factory(cache)
.createMediaSource(MediaItem.fromUri(uri))
setMediaSource(source)
prepare()
volume = 0f
playWhenReady = false
repeatMode = Player.REPEAT_MODE_ONE
}
private val _volumeFlow = MutableStateFlow(volume)
private val _isPlayingFlow = MutableStateFlow(isPlaying)
val isPlayingFlow = _isPlayingFlow.asStateFlow()
val isMutedFlow = _volumeFlow.map {
it <= 0.01f
}
private val _emittedInternal = MutableStateFlow(VideoTime.default)
val currentTimeStamp = merge(flow {
while (true) {
emit(
VideoTime(
currentTime = currentPosition.coerceAtLeast(0),
contentDuration = contentDuration.coerceAtLeast(0),
bufferTime = bufferedPosition.coerceAtLeast(0)
)
)
delay(50)
}
}, _emittedInternal)
val seekMs = 5000L
fun isMuted() = volume <= 0.01f
override fun seekBack() {
val next = currentPosition - seekMs
if (next < 0) seekToMillis(0) else seekToMillis(next)
}
fun seekToMillis(time: Long) {
seekTo(time)
_emittedInternal.tryEmit(
VideoTime(
currentTime = time, contentDuration = contentDuration, bufferTime = bufferedPosition
)
)
}
override fun seekForward() {
val next = currentPosition + seekMs
if (next > contentDuration) seekToMillis(contentDuration) else seekToMillis(next)
}
suspend fun muteOrUnMute() {
volume = if (isMuted()) 1f
else 0f
_volumeFlow.emit(volume)
}
fun setNewVolume(newVolume: Float) {
volume = if (newVolume <= 0.01f) 0f
else 1f
_volumeFlow.tryEmit(volume)
}
suspend fun onPlayOrResume() {
if (isPlaying) pause()
else play()
_isPlayingFlow.emit(isPlaying)
}
companion object {
private const val keyMillis = "MILLIS"
private const val keyVolume = "VOLUME"
private const val keyPlay = "PLAY"
private const val keyUri = "URI"
val saver
get() = mapSaver(save = {
mapOf(
keyMillis to maxOf(it.currentPosition, 0),
keyVolume to it.volume,
keyPlay to it.isPlaying,
keyUri to it.uri
)
}, restore = {
val uri = it[keyUri] as? String
val volume = it[keyVolume] as? Float ?: 0f
val isPlaying = it[keyPlay] as? Boolean ?: false
val millis = it[keyMillis] as? Long ?: 0
if (uri != null)
MyPlayer(uri).also {
it.setNewVolume(volume)
it.seekToMillis(millis)
it.playWhenReady = isPlaying
}
else null
})
}
}
@Composable
private fun PlayIcon(
modifier: Modifier = Modifier,
infiniteTransition: InfiniteTransition = rememberInfiniteTransition(label = "")
) {
val pulsate by infiniteTransition.animateFloat(
initialValue = 0.1f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1200), RepeatMode.Reverse),
label = ""
)
Icon(
tint = LocalContentColor.current.copy(alpha = pulsate),
imageVector = Icons.Default.PlayArrow,
contentDescription = "",
modifier = modifier.size(24.dp)
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment