package com.suno.android.common_ui.components.omni import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOutQuint import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.media3.common.Player import com.suno.android.common_ui.R import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.SunoTheme import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR enum class RepeatMode( val exoPlayerRepeatMode: Int, ) { REPEAT_MODE_NONE(Player.REPEAT_MODE_OFF), REPEAT_MODE_ONE(Player.REPEAT_MODE_ONE), REPEAT_MODE_ALL(Player.REPEAT_MODE_ALL), } @Composable fun OmniPlayerPlaybackControls( timestamp: String, isPlaying: Boolean, isShuffling: Boolean, canSeek: Boolean, repeatMode: RepeatMode, percentageComplete: Float, onReplayClicked: () -> Unit, onPlayPauseClicked: () -> Unit, onShuffleClicked: () -> Unit, onRepeatModeClicked: () -> Unit, onScrubbed: (position: Float) -> Unit, isShowRepeatGateEnabled: Boolean = false, isShowShuffleGateEnabled: Boolean = false, ) { var sliderPosition by remember(percentageComplete) { mutableFloatStateOf(percentageComplete) } Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Row { IconButton( onClick = { onReplayClicked.invoke() }, ) { Icon( imageVector = ImageVector.vectorResource( id = R.drawable.replay, ), tint = Color.White, contentDescription = "Replay", ) } IconButton( onClick = { onPlayPauseClicked.invoke() }, ) { if (isPlaying) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.pause), tint = Color.White, contentDescription = "Pause", ) } else { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.play), tint = Color.White, contentDescription = "Play", ) } } } val animatedSliderPosition = remember { Animatable( initialValue = sliderPosition, ) } var lastUpdate by remember { mutableStateOf(0L) } val coroutineScope = rememberCoroutineScope() LaunchedEffect(sliderPosition) { // skip updating positions that were snappedTo if (sliderPosition == animatedSliderPosition.value) return@LaunchedEffect val currentTime = System.currentTimeMillis() when { lastUpdate == 0L -> { animatedSliderPosition.snapTo(sliderPosition) lastUpdate = currentTime } sliderPosition == 0f -> { animatedSliderPosition.animateTo( targetValue = sliderPosition, animationSpec = tween( easing = EaseOutQuint, ), ) } else -> { val timeDiff = currentTime - lastUpdate lastUpdate = currentTime animatedSliderPosition.animateTo( targetValue = sliderPosition, animationSpec = tween( durationMillis = timeDiff.toInt(), easing = LinearEasing, ), ) } } } Slider( modifier = Modifier.weight(1f), thumb = { Canvas( modifier = Modifier .size(12.dp) .offset(y = 2.dp), ) { drawCircle( color = Color.White, radius = size.minDimension / 2, ) } }, track = { sliderPositions -> Canvas( modifier = Modifier .fillMaxWidth() .height(4.dp), ) { // Draw track drawLine( color = Color.LightGray, start = Offset(0f, size.height / 2), end = Offset(size.width, size.height / 2), strokeWidth = size.height, cap = StrokeCap.Round, ) // Draw progress drawLine( color = Color.White, start = Offset(0f, size.height / 2), end = Offset( size.width * sliderPositions.valueRange.endInclusive, size.height / 2, ), strokeWidth = size.height, cap = StrokeCap.Round, ) } }, value = animatedSliderPosition.value, onValueChange = { thePercent -> if (!canSeek) return@Slider coroutineScope.launch { animatedSliderPosition.snapTo(thePercent) sliderPosition = thePercent onScrubbed(thePercent) // Call the onScrubbed function when the user is dragging the slider } }, onValueChangeFinished = { if (!canSeek) return@Slider // This is called when the user releases the slider // You might want to update the player position here onScrubbed(sliderPosition) }, colors = SliderDefaults.colors( thumbColor = Color.White, activeTrackColor = Color.White, inactiveTrackColor = Color.LightGray, ), ) Text( modifier = Modifier.width(48.dp), textAlign = TextAlign.Center, color = Color.White, style = ExtendedTheme.typography.smallBody, text = timestamp, ) if (isShowShuffleGateEnabled) { ShuffleButton( isShuffling = isShuffling, onShuffleClicked = onShuffleClicked, ) } if (isShowRepeatGateEnabled) { RepeatButton( repeatMode = repeatMode, onRepeatModeClicked = onRepeatModeClicked, ) } } } @Composable private fun ShuffleButton( isShuffling: Boolean, onShuffleClicked: () -> Unit, ) { IconButton( modifier = Modifier.background( animateColorAsState( if (isShuffling) { Color.White.copy(alpha = 0.8f) } else { Color.Transparent }, ).value, shape = CircleShape, ), onClick = { onShuffleClicked.invoke() }, ) { Icon( imageVector = ImageVector .vectorResource(id = androidx.media3.session.R.drawable.media3_icon_shuffle_on), tint = if (isShuffling) ExtendedTheme.colors.aukPink else Color.White, contentDescription = stringResource(CommonResR.string.shuffle), ) } } @Composable private fun RepeatButton( repeatMode: RepeatMode, onRepeatModeClicked: () -> Unit, ) { IconButton( modifier = Modifier.background( animateColorAsState( if (repeatMode != RepeatMode.REPEAT_MODE_NONE) { Color.White.copy(alpha = 0.8f) } else { Color.Transparent }, ).value, shape = CircleShape, ), onClick = { onRepeatModeClicked.invoke() }, ) { Crossfade(targetState = repeatMode) { state -> val repeatModeIcon = when (state) { RepeatMode.REPEAT_MODE_ONE -> R.drawable.repeat_single RepeatMode.REPEAT_MODE_ALL -> R.drawable.repeat else -> R.drawable.repeat } val repeatModeTint by animateColorAsState( when (state) { RepeatMode.REPEAT_MODE_ONE -> ExtendedTheme.colors.aukPink RepeatMode.REPEAT_MODE_ALL -> ExtendedTheme.colors.aukPink else -> Color.White }, ) Icon( imageVector = ImageVector.vectorResource(id = repeatModeIcon), tint = repeatModeTint, contentDescription = stringResource(CommonResR.string.repeat), ) } } } @Preview @Composable private fun OmniPlayerMainControls_Preview() { SunoTheme { OmniPlayerPlaybackControls( timestamp = "1:23", isPlaying = false, isShuffling = false, canSeek = true, repeatMode = RepeatMode.REPEAT_MODE_NONE, percentageComplete = 0.5f, onReplayClicked = {}, onPlayPauseClicked = {}, onScrubbed = {}, onShuffleClicked = {}, onRepeatModeClicked = {}, ) } }