Last active
September 3, 2023 10:12
-
-
Save edivad1999/c478425c8456bd3703313a5887e0063c to your computer and use it in GitHub Desktop.
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
@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