package com.suno.android.ui.screens.create.audio import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.OptIn import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import coil3.compose.AsyncImage import coil3.memory.MemoryCache import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.crossfade import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.model.Url import com.suno.android.common_data.media.model.Waveform import com.suno.android.common_data.upload.model.UploadingFile import com.suno.android.common_res.R import com.suno.android.common_ui.components.aura.auraBackground import com.suno.android.common_ui.components.buttons.RoundedButton import com.suno.android.common_ui.components.buttons.RoundedCornerButton import com.suno.android.common_ui.components.player.PlayPauseButton import com.suno.android.common_ui.components.player.PlaybackTimeText import com.suno.android.common_ui.components.preview.SunoPreview import com.suno.android.common_ui.components.timebox.TimeBox import com.suno.android.common_ui.components.timebox.TimeBoxPlaceableScope import com.suno.android.common_ui.components.timebox.rememberTimeBoxStateOf import com.suno.android.common_ui.components.waveform.AudioWaveform import com.suno.android.common_ui.components.waveform.AudioWaveformState import com.suno.android.common_ui.components.waveform.StartOnlyCropHandle import com.suno.android.common_ui.components.waveform.div import com.suno.android.common_ui.extensions.collectCurrentPositionAsState import com.suno.android.common_ui.extensions.collectDurationAsState import com.suno.android.common_ui.models.FakeSeekInteraction import com.suno.android.common_ui.models.SeekInteraction import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.preview.FakePlayer import com.suno.android.preview.FakeSongListData import com.suno.android.ui.screens.create.audio.CreateAudioState.AudioSourceOption import com.suno.android.ui.screens.create.audio.model.AudioTaskParametersState import com.suno.android.ui.screens.create.audio.task.extend.AudioExtendTaskEvent import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlin.random.Random import kotlin.time.Duration.Companion.seconds import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUiR @Composable internal fun AudioCreateSection( state: CreateAudioState, showEntryPointButton: Boolean, effects: Flow, onStartButtonClick: () -> Unit, onAudioSelected: (Uri) -> Unit, onUploadCancelClick: () -> Unit, onAudioRemoveClick: () -> Unit, onAudioFailureDismissClick: () -> Unit, onChangeCreateTypeClick: () -> Unit, onPlayClick: () -> Unit, onPauseClick: () -> Unit, seekInteraction: SeekInteraction, modifier: Modifier = Modifier, ) { var thumbnailCacheKey by remember { mutableStateOf(null) } EffectHandler( effects = effects, onAudioSelected = onAudioSelected, ) AnimatedContent( targetState = state, contentKey = { it::class }, transitionSpec = { fadeIn() togetherWith fadeOut() }, modifier = modifier, ) { currentState -> when (currentState) { is CreateAudioState.None -> { // clear thumbnail cache so placeholder will show again LaunchedEffect(Unit) { thumbnailCacheKey = null } if (showEntryPointButton) { CreateAudioButton( onClick = onStartButtonClick, modifier = Modifier.fillMaxWidth(), ) } } is CreateAudioState.Uploading -> AudioUploadProgressItem( state = currentState, onUploadCancelClick = onUploadCancelClick, placeholderCacheKey = thumbnailCacheKey, onPlaceholderCacheKeyReceived = { thumbnailCacheKey = it }, modifier = Modifier.fillMaxWidth(), ) is CreateAudioState.Completed -> AudioItem( state = currentState, onChangeCreateTypeClick = onChangeCreateTypeClick, onRemoveClick = onAudioRemoveClick, onPlayClick = onPlayClick, onPauseClick = onPauseClick, placeholderCacheKey = thumbnailCacheKey, onPlaceholderCacheKeyReceived = { thumbnailCacheKey = it }, seekInteraction = seekInteraction, modifier = Modifier.fillMaxWidth(), ) is CreateAudioState.Failed -> AudioItemFailed( state = currentState, onRemoveClick = onAudioFailureDismissClick, modifier = Modifier.fillMaxWidth(), ) } } } @Composable private fun EffectHandler( effects: Flow, onAudioSelected: (Uri) -> Unit, ) { val audioPickerLauncher = rememberLauncherForActivityResult( ActivityResultContracts.GetContent(), ) { if (it != null) { onAudioSelected(it) } } LaunchedEffect(effects) { effects.collect { when (it) { CreateAudioEffect.RequestMusicPicker -> audioPickerLauncher.launch("audio/*") } } } } @Composable internal fun CreateAudioButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { var isExpanded by remember { mutableStateOf(false) } Box( modifier = modifier, ) { RoundedCornerButton( buttonText = stringResource(CommonResR.string.audio), leadingPainter = painterResource(com.suno.android.common_ui.R.drawable.plus), onClick = onClick, backgroundColor = ExtendedTheme.colors.backgroundTertiary, textColor = ExtendedTheme.colors.textPrimary, modifier = modifier .heightIn(56.dp) .fillMaxWidth(), ) } } @Composable internal fun AudioUploadProgressItem( state: CreateAudioState.Uploading, onUploadCancelClick: () -> Unit, modifier: Modifier = Modifier, placeholderCacheKey: MemoryCache.Key? = null, onPlaceholderCacheKeyReceived: (MemoryCache.Key?) -> Unit = {}, ) { val uploadingAudioFile = state.uploadFile Surface( shape = MaterialTheme.shapes.large, color = ExtendedTheme.colors.backgroundSecondary, modifier = modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { AudioSectionThumbnail( url = state.uploadFile.thumbnail, placeholderCacheKey = placeholderCacheKey, onPlaceholderCacheKeyReceived = onPlaceholderCacheKeyReceived, modifier = Modifier.size(32.dp), ) Column( verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f), ) { Text( text = state.uploadFile.name, style = ExtendedTheme.typography.cardTitle, color = ExtendedTheme.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = stringResource(R.string.uploading_ellipsis), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.foregroundInactive, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } val animatedProgress by animateFloatAsState( targetValue = (uploadingAudioFile as? UploadingFile.HasProgress)?.progress ?: 0f, ) LinearProgressIndicator( progress = { animatedProgress }, color = ExtendedTheme.colors.sunoPink, trackColor = ExtendedTheme.colors.borderPrimary.copy(alpha = .1f), modifier = Modifier.widthIn(max = 80.dp), ) RoundedButton( buttonText = stringResource(R.string.cancel), textStyle = ExtendedTheme.typography.smallSubtitle, onClick = onUploadCancelClick, modifier = Modifier.heightIn(40.dp), ) } } } @OptIn(UnstableApi::class) @Composable fun AudioItem( state: CreateAudioState.Completed, onChangeCreateTypeClick: () -> Unit, onRemoveClick: () -> Unit, onPlayClick: () -> Unit, onPauseClick: () -> Unit, seekInteraction: SeekInteraction, modifier: Modifier = Modifier, placeholderCacheKey: MemoryCache.Key? = null, onPlaceholderCacheKeyReceived: (MemoryCache.Key?) -> Unit = {}, ) { val playPauseButtonState = rememberPlayPauseButtonState(state.player) Surface( shape = MaterialTheme.shapes.large, color = ExtendedTheme.colors.backgroundSecondary, modifier = modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { PlayPauseButton( playPauseButtonState = playPauseButtonState, onPlayClick = onPlayClick, onPauseClick = onPauseClick, shape = CircleShape, modifier = Modifier.size(40.dp), iconModifier = Modifier.size(16.dp), ) AudioSectionThumbnail( url = state.clip.albumImageUrl, placeholderCacheKey = placeholderCacheKey, onPlaceholderCacheKeyReceived = onPlaceholderCacheKeyReceived, modifier = Modifier.size(32.dp), ) Column( verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f), ) { Text( text = state.clip.nowPlayingTitle ?: "", style = ExtendedTheme.typography.cardTitle, color = ExtendedTheme.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) PlaybackTimeText( player = state.player, textAlign = TextAlign.Start, style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.foregroundInactive, ) } if (state.showTaskSwitchButton) { RoundedButton( buttonText = state.configurations.selectedCreateTask.name(), onClick = onChangeCreateTypeClick, trailingPainter = painterResource(CommonUiR.drawable.chevron_down), textStyle = ExtendedTheme.typography.smallSubtitle, modifier = Modifier.heightIn(40.dp), ) } IconButton( onClick = onRemoveClick, ) { Icon( painter = painterResource(CommonUiR.drawable.close), contentDescription = stringResource(CommonResR.string.remove), tint = ExtendedTheme.colors.textTertiary, ) } } HorizontalDivider() val position by state.player.collectCurrentPositionAsState() val duration by state.player.collectDurationAsState() AnimatedVisibility( visible = state.waveform?.samples?.isNotEmpty() == true, ) { BoxWithConstraints { TimeBox( state = rememberTimeBoxStateOf( resolution = maxWidth / duration, ), modifier = Modifier .height(80.dp) .fillMaxWidth(), ) { AudioWaveform( state = remember(state.waveform, duration) { AudioWaveformState( waveform = state.waveform?.samples ?: FloatArray(100) { Random.nextFloat() }, duration = duration, ) }, modifier = Modifier .fillMaxHeight() .border( width = 1.dp, color = ExtendedTheme.colors.borderPrimary.copy(alpha = .1f), shape = MaterialTheme.shapes.medium, ) .clip(MaterialTheme.shapes.medium), ) AudioItemTaskParameterContent( parametersState = state.taskParametersState, ) Spacer( Modifier .width(2.dp) .fillMaxHeight() .background(ExtendedTheme.colors.textPrimary) .atTime(position) .timeDraggableWithState( backingTime = position, onDragStarted = { seekInteraction.onSeekStarted() }, onDragStopped = { seekInteraction.onSeekStopped() }, onTimeChanged = { newPosition -> seekInteraction.onSeekPositionChanged(newPosition) }, ), ) } } } } } } @Composable private fun TimeBoxPlaceableScope.AudioItemTaskParameterContent( parametersState: AudioTaskParametersState, ) { when (parametersState) { AudioTaskParametersState.Cover -> Unit is AudioTaskParametersState.Extend -> { val state by parametersState.controller.state.collectAsStateWithLifecycle() val duration by state.player.collectDurationAsState() StartOnlyCropHandle( startTime = state.startTime, duration = duration, onStartTimeDrag = { delta -> parametersState.controller.sendEvent( AudioExtendTaskEvent.UpdateStartTime(state.startTime + delta), ) }, ) } AudioTaskParametersState.NoOp -> Unit } } const val IMAGE_CROSSFADE_MILLIS = 200 @Composable private fun AudioSectionThumbnail( url: Url?, modifier: Modifier = Modifier, placeholderCacheKey: MemoryCache.Key? = null, onPlaceholderCacheKeyReceived: (MemoryCache.Key?) -> Unit, ) { val showPlaceholder = placeholderCacheKey == null var showBackground by remember { mutableStateOf(showPlaceholder) } var showLoader by remember { mutableStateOf(showPlaceholder) } val coroutineScope = rememberCoroutineScope() val listener = remember { object : ImageRequest.Listener { override fun onSuccess( request: ImageRequest, result: SuccessResult, ) { super.onSuccess(request, result) onPlaceholderCacheKeyReceived(result.memoryCacheKey) coroutineScope.launch { showLoader = false delay(200) showBackground = false } } } } val loaderAlpha by animateFloatAsState( targetValue = if (showLoader) { 1f } else { 0f }, ) Box( contentAlignment = Alignment.Center, modifier = modifier .defaultMinSize(32.dp, 32.dp) .aspectRatio(1f) .clip(MaterialTheme.shapes.small), ) { if (showBackground) { Spacer( modifier = Modifier .fillMaxSize() .auraBackground(), ) } AsyncImage( model = ImageRequest.Builder(LocalContext.current) .placeholderMemoryCacheKey(placeholderCacheKey) .data(url) .crossfade(IMAGE_CROSSFADE_MILLIS) .listener(listener) .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) CircularProgressIndicator( color = ExtendedTheme.colors.backgroundInvert, modifier = Modifier .size(20.dp) .alpha(loaderAlpha), ) } } @Composable private fun AudioItemFailed( state: CreateAudioState.Failed, onRemoveClick: () -> Unit, modifier: Modifier = Modifier, ) { Surface( shape = MaterialTheme.shapes.large, color = ExtendedTheme.colors.backgroundSecondary, modifier = modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(16.dp), ) { Column( verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f), ) { Text( text = stringResource(R.string.upload_failed), style = ExtendedTheme.typography.cardTitle, color = ExtendedTheme.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = stringResource(R.string.remove_to_try_again), style = ExtendedTheme.typography.body, lineHeight = ExtendedTheme.typography.body.fontSize, color = ExtendedTheme.colors.warning, ) } IconButton( onClick = onRemoveClick, ) { Icon( painter = painterResource(CommonUiR.drawable.close), contentDescription = stringResource(CommonResR.string.remove), tint = ExtendedTheme.colors.textTertiary, ) } } } } @Preview @Composable private fun AudioUploadItem_Loading_Preview() { SunoPreview { AudioUploadProgressItem( state = CreateAudioState.Uploading( configurations = CreateAudioState.Configurations( sourceOptions = AudioSourceOption.entries.toPersistentList(), createTaskOptions = CreateAudioState.AudioCreateTask.entries.toPersistentList(), ), uploadFile = UploadingFile.Initializing( name = "My cool Song nwith a reaaaaallyy long name", thumbnail = null, ), ), onUploadCancelClick = {}, ) } } @Preview @Composable private fun AudioUploadItem_Loaded_Preview() { SunoPreview { AudioItem( state = CreateAudioState.Completed( uploadFile = UploadingFile.Success( uploadId = Id("test"), clipId = Id(), name = "My cool Song", thumbnail = null, ), player = FakePlayer(), clip = FakeSongListData.cat.asLocalClipData(), configurations = CreateAudioState.Configurations( sourceOptions = AudioSourceOption.entries.toPersistentList(), createTaskOptions = CreateAudioState.AudioCreateTask.entries.toPersistentList(), ), waveform = Waveform( samples = FloatArray(100) { Random.nextFloat() }, duration = 10.seconds, ), taskParametersState = AudioTaskParametersState.Cover, ), onChangeCreateTypeClick = {}, onRemoveClick = {}, onPlayClick = {}, onPauseClick = {}, seekInteraction = FakeSeekInteraction(), ) } } @PreviewLightDark @Composable private fun AudioUploadItem_Failure_Preview() { SunoPreview { AudioItemFailed( state = CreateAudioState.Failed( configurations = CreateAudioState.Configurations( sourceOptions = AudioSourceOption.entries.toPersistentList(), createTaskOptions = CreateAudioState.AudioCreateTask.entries.toPersistentList(), ), ), onRemoveClick = {}, ) } }