package com.suno.android.ui.screens.create.text import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.MusicOff import androidx.compose.material.icons.filled.Replay import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable 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.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import com.suno.android.common_networking.remote.session.ModelFeatures import com.suno.android.common_ui.components.preview.SunoPreview import com.suno.android.common_ui.models.SeekInteraction import com.suno.android.common_ui.models.text import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.ui.bottom_sheets.projects.SelectProjectBottomSheet import com.suno.android.ui.screens.create.audio.AudioCreateSection import com.suno.android.ui.screens.create.audio.CreateAudioEffect import com.suno.android.ui.screens.create.audio.CreateAudioEvent import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlin.time.Duration import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR @Composable fun CreateTextCustomMode( modifier: Modifier = Modifier, viewState: CreateTextState, sendEvent: (CreateTextEvent) -> Unit, audioEffects: Flow, seekInteraction: SeekInteraction, contentPadding: PaddingValues = PaddingValues(), ) { val scope = rememberCoroutineScope() val selectProjectSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, ) var showSelectProjectBottomSheet by remember { mutableStateOf(false) } LazyColumn( contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier .fillMaxSize() .consumeWindowInsets(contentPadding), ) { item { AudioCreateSection( onStartButtonClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnAddAudioClick)) }, showEntryPointButton = viewState.showAudioCreateEntryPoint, state = viewState.audioState, effects = audioEffects, onUploadCancelClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnCancelUploadClick)) }, onAudioRemoveClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnRemoveUploadClick)) }, onAudioFailureDismissClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnDismissFailureClick)) }, onChangeCreateTypeClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnChangeCreateTypeClick)) }, onPlayClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnPlayClick)) }, onPauseClick = { sendEvent(CreateTextEvent.AudioEvent(CreateAudioEvent.OnPauseClick)) }, onAudioSelected = { sendEvent( CreateTextEvent.AudioEvent(CreateAudioEvent.OnAudioToUploadSelected(it)), ) }, seekInteraction = seekInteraction, ) } item { LyricsInputCard( isInstrumental = viewState.isInstrumental, onInstrumentalCheckChange = { sendEvent( CreateTextEvent.OnInstrumentalModeToggled( isInstrumentalMode = it, ), ) }, lyricsInput = viewState.lyricsInput, onLyricTextInput = { lyricsString -> sendEvent(CreateTextEvent.OnLyricsTextInput(lyricsText = lyricsString)) }, enhanceLyricsLoading = viewState.lyricGenerationLoading, onEnhanceLyricsClicked = { sendEvent(CreateTextEvent.OnEnhanceLyrics) }, onResetLyrics = { sendEvent(CreateTextEvent.OnResetLyrics) }, resetFieldsFlagEnabled = viewState.isResetFieldsGateEnabled, canUndo = viewState.canUndoLyrics, onUndo = { sendEvent(CreateTextEvent.OnUndoLyrics) }, ) } item { StylesInputCard( styleInput = viewState.styleInput, onStyleTextInput = { styleString -> sendEvent(CreateTextEvent.OnStyleTextInput(styleText = styleString)) }, enhanceStyleFlagEnabled = viewState.isEnhanceStyleGateEnabled, enhanceStyleLoading = viewState.styleEnhanceLoading, onEnhanceStyleClicked = { sendEvent(CreateTextEvent.OnEnhanceStyle) }, onResetStyle = { sendEvent(CreateTextEvent.OnResetStyle) }, resetFieldsFlagEnabled = viewState.isResetFieldsGateEnabled, recommendStyles = viewState.recommendStyles.toImmutableList(), isOverStyleLimit = viewState.isOverStyleLimit, maxStyleChars = viewState.maxStyleContextLength, onRefreshRecommendedStyles = { sendEvent(CreateTextEvent.OnRefreshRecommendedStyles) }, ) } // this is temporary // eventually we'll build a mechanism for all feature-based paywalls so this will be rehauled val doesModelContainFeature = viewState.activeModel?.features ?.contains(ModelFeatures.CreateControlSliders.value) ?: false val isFeatureAccessible = viewState.accessibleFeatures .any { it.name == ModelFeatures.CreateControlSliders.value } val displayAdvancedOptions = doesModelContainFeature && isFeatureAccessible if (viewState.isShowCustomAdvancedOptionsGateEnabled) { item { AnimatedVisibility( visible = displayAdvancedOptions, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { AdvancedOptionsCard( weirdnessValue = viewState.weirdnessValue, onWeirdnessChanged = { sendEvent(CreateTextEvent.OnWeirdnessChanged(weirdnessValue = it)) }, styleInfluenceValue = viewState.styleInfluenceValue, onStyleInfluenceChanged = { sendEvent( CreateTextEvent.OnStyleInfluenceChanged(styleInfluenceValue = it), ) }, audioInfluenceValue = viewState.audioInfluenceValueIfDisplayed, onAudioInfluenceChanged = { sendEvent( CreateTextEvent.OnAudioInfluenceChanged(audioInfluenceValue = it), ) }, displayAudioInfluenceSlider = viewState.isShowAudioInfluenceSliderGateEnabled, stylesToExclude = viewState.stylesToExclude, onStylesToExcludeChanged = { sendEvent(CreateTextEvent.OnStylesToExcludeChanged(stylesToExclude = it)) }, onResetValues = { sendEvent(CreateTextEvent.OnResetAdvancedOptions) }, ) } } } item { // custom-mode title Card( modifier = Modifier .fillMaxWidth() .animateContentSize() .padding(bottom = 12.dp), colors = CardDefaults.cardColors( containerColor = ExtendedTheme.colors.backgroundTertiary, ), shape = MaterialTheme.shapes.small, ) { Column { Row( modifier = Modifier .padding(horizontal = 16.dp) .height(56.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( painter = painterResource(CommonUIR.drawable.music), contentDescription = null, tint = ExtendedTheme.colors.textPrimary, ) BasicTextField( modifier = Modifier .weight(1f) .fillMaxWidth(), value = viewState.title.text(), onValueChange = { sendEvent(CreateTextEvent.OnTitleTextInput(titleText = it)) }, textStyle = ExtendedTheme.typography.body.copy(color = ExtendedTheme.colors.textPrimary), cursorBrush = SolidColor(ExtendedTheme.colors.aukPink), decorationBox = { innerTextField -> Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.CenterStart, ) { innerTextField() if (viewState.title.text().isEmpty()) { Text( text = stringResource(id = CommonResR.string.title_placeholder), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textTertiary, ) } } }, ) } if (viewState.isProjectSelectionGateEnabled) { viewState.selectedProject?.name?.let { projectName -> HorizontalDivider( color = ExtendedTheme.colors.backgroundSecondary, ) Row( modifier = Modifier .padding(horizontal = 16.dp) .height(56.dp) .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon( imageVector = Icons.Default.Folder, contentDescription = null, tint = ExtendedTheme.colors.textPrimary, ) Text( modifier = Modifier.weight(1f), text = stringResource(CommonResR.string.workspace), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Row( modifier = Modifier .clip(CircleShape) .clickable { scope.launch { showSelectProjectBottomSheet = true selectProjectSheetState.show() } } .height(40.dp) .background(ExtendedTheme.colors.backgroundQuarternary) .padding(horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = projectName, style = ExtendedTheme.typography.body.copy( textAlign = TextAlign.Center, ), color = ExtendedTheme.colors.textPrimary, ) } } } } } } } } if (showSelectProjectBottomSheet) { SelectProjectBottomSheet( sheetState = selectProjectSheetState, projects = viewState.projects, selectedProjectId = viewState.selectedProject?.id, onProjectSelected = { projectMetadata -> sendEvent(CreateTextEvent.OnProjectSelected(projectMetadata)) }, onCreateProject = { projectName -> sendEvent(CreateTextEvent.OnCreateProject(projectName)) }, onDismiss = { scope.launch { selectProjectSheetState.hide() showSelectProjectBottomSheet = false } }, onLoadMoreProjects = { sendEvent(CreateTextEvent.OnLoadMoreProjects) }, isMoreProjectsLoading = viewState.loadingMoreProjects, areProjectsLoading = viewState.projectsLoading, ) } } @Composable fun AdvancedOptionsCard( stylesToExclude: String?, onStylesToExcludeChanged: (String?) -> Unit, weirdnessValue: Double, onWeirdnessChanged: (Double) -> Unit, styleInfluenceValue: Double, onStyleInfluenceChanged: (Double) -> Unit, audioInfluenceValue: Double?, onAudioInfluenceChanged: (Double) -> Unit, displayAudioInfluenceSlider: Boolean = false, onResetValues: () -> Unit, ) { var expanded by remember { mutableStateOf(false) } val canReset = !stylesToExclude.isNullOrEmpty() || weirdnessValue != WEIRDNESS_DEFAULT || styleInfluenceValue != STYLE_INFLUENCE_DEFAULT || (displayAudioInfluenceSlider && audioInfluenceValue != AUDIO_INFLUENCE_DEFAULT) Card( modifier = Modifier .fillMaxWidth() .animateContentSize(), colors = CardDefaults.cardColors( containerColor = ExtendedTheme.colors.backgroundTertiary, ), shape = MaterialTheme.shapes.small, ) { Column( modifier = Modifier.padding(bottom = 16.dp, start = 16.dp, end = 16.dp), ) { AdvancedOptionsHeader( expanded = expanded, onHeaderClicked = { expanded = expanded.not() }, canReset = canReset, resetValues = { onResetValues.invoke() }, ) AnimatedVisibility(expanded) { Column { ExcludeStylesTextField( stylesToExclude = stylesToExclude, onStylesToExcludeChanged = { onStylesToExcludeChanged(it) }, ) WeirdnessSlider( weirdnessValue = weirdnessValue, onWeirdnessChanged = { onWeirdnessChanged(it) }, ) StylesInfluenceSlider( styleInfluenceValue = styleInfluenceValue, onStyleInfluenceChanged = { onStyleInfluenceChanged(it) }, ) AnimatedVisibility( visible = displayAudioInfluenceSlider, ) { AudioInfluenceSlider( audioInfluenceValue = audioInfluenceValue, onAudioInfluenceChanged = { onAudioInfluenceChanged(it) }, ) } } } } } } @Composable fun AdvancedOptionsHeader( expanded: Boolean, onHeaderClicked: () -> Unit, canReset: Boolean, resetValues: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, ) { onHeaderClicked.invoke() } .padding(top = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(CommonResR.string.advanced_options), style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textPrimary, fontWeight = FontWeight.Medium, ) AnimatedVisibility( visible = canReset, enter = fadeIn(), exit = fadeOut(), ) { IconButton( colors = IconButtonDefaults.iconButtonColors( containerColor = ExtendedTheme.colors.backgroundTertiary, contentColor = ExtendedTheme.colors.textSecondary, ), onClick = resetValues, modifier = Modifier.size(32.dp), ) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Default.Replay, contentDescription = stringResource(CommonResR.string.reset), ) } } } val iconRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, animationSpec = tween(durationMillis = 300), ) Icon( imageVector = Icons.Default.KeyboardArrowDown, contentDescription = stringResource(if (expanded) CommonResR.string.collapse else CommonResR.string.expand), tint = ExtendedTheme.colors.textPrimary, modifier = Modifier .rotate(iconRotation) .background( ExtendedTheme.colors.backgroundTertiary, CircleShape, ) .padding(4.dp) .size(24.dp), ) } } @Composable fun ExcludeStylesTextField( stylesToExclude: String?, onStylesToExcludeChanged: (String?) -> Unit, ) { Box( modifier = Modifier .fillMaxWidth() .padding(top = 12.dp, bottom = 8.dp) .height(40.dp), contentAlignment = Alignment.Center, ) { BasicTextField( value = stylesToExclude ?: "", onValueChange = { onStylesToExcludeChanged(it) }, textStyle = TextStyle.Default.copy( color = ExtendedTheme.colors.textPrimary, ), maxLines = 1, cursorBrush = SolidColor(ExtendedTheme.colors.aukPink), decorationBox = @Composable { innerTextField -> Row( modifier = Modifier .fillMaxSize() .background( color = ExtendedTheme.colors.backgroundPrimary, shape = MaterialTheme.shapes.small, ) .padding(start = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.size(24.dp), imageVector = Icons.Default.MusicOff, tint = ExtendedTheme.colors.textSecondary, contentDescription = stringResource(CommonResR.string.exclude_styles), ) Row( modifier = Modifier .weight(1f) .padding(start = 8.dp), ) { Box { innerTextField() if (stylesToExclude.isNullOrEmpty()) { Text( text = stringResource(CommonResR.string.enter_styles_to_exclude), style = ExtendedTheme.typography.smallBody, color = ExtendedTheme.colors.foregroundInactive, ) } } } } }, ) } } @Composable fun WeirdnessSlider( weirdnessValue: Double, onWeirdnessChanged: (Double) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(40.dp) .background( color = ExtendedTheme.colors.backgroundPrimary, shape = MaterialTheme.shapes.small, ) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { val weirdnessTooltips = mapOf( 0..33 to stringResource(CommonResR.string.weirdness_scale_1), 34..66 to stringResource(CommonResR.string.weirdness_scale_2), 67..80 to stringResource(CommonResR.string.weirdness_scale_3), 81..100 to stringResource(CommonResR.string.weirdness_scale_4), ) CustomSlider( label = stringResource(CommonResR.string.weirdness), value = weirdnessValue, onValueChange = { onWeirdnessChanged(it) }, onDoubleTap = { onWeirdnessChanged(WEIRDNESS_DEFAULT) }, generalInfoTooltipText = stringResource(CommonResR.string.weirdness_tooltip_text), tooltipRanges = weirdnessTooltips, ) } } @Composable fun StylesInfluenceSlider( styleInfluenceValue: Double, onStyleInfluenceChanged: (Double) -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(40.dp) .background( color = ExtendedTheme.colors.backgroundPrimary, shape = MaterialTheme.shapes.small, ) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { val styleInfluenceTooltips = mapOf( 0..33 to stringResource(CommonResR.string.style_influence_scale_1), 34..66 to stringResource(CommonResR.string.style_influence_scale_2), 67..100 to stringResource(CommonResR.string.style_influence_scale_3), ) CustomSlider( label = stringResource(CommonResR.string.style_influence), value = styleInfluenceValue, onValueChange = { onStyleInfluenceChanged(it) }, onDoubleTap = { onStyleInfluenceChanged(STYLE_INFLUENCE_DEFAULT) }, generalInfoTooltipText = stringResource(CommonResR.string.style_influence_tooltip_text), tooltipRanges = styleInfluenceTooltips, ) } } @Composable fun AudioInfluenceSlider( audioInfluenceValue: Double?, onAudioInfluenceChanged: (Double) -> Unit, ) { if (audioInfluenceValue == null) return Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp) .height(40.dp) .background( color = ExtendedTheme.colors.backgroundPrimary, shape = MaterialTheme.shapes.small, ) .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { val audioInfluenceTooltips = mapOf( 0..33 to stringResource(CommonResR.string.style_influence_scale_1), 34..66 to stringResource(CommonResR.string.style_influence_scale_2), 67..100 to stringResource(CommonResR.string.style_influence_scale_3), ) CustomSlider( label = stringResource(CommonResR.string.audio_influence), value = audioInfluenceValue, onValueChange = { onAudioInfluenceChanged(it) }, onDoubleTap = { onAudioInfluenceChanged(AUDIO_INFLUENCE_DEFAULT) }, generalInfoTooltipText = stringResource(CommonResR.string.audio_influence_tooltip_text), tooltipRanges = audioInfluenceTooltips, ) } } @PreviewLightDark @Composable private fun CreateCustomTextMode_Preview() { SunoPreview { CreateTextCustomMode( audioEffects = emptyFlow(), viewState = CreateTextState(), sendEvent = {}, seekInteraction = object : SeekInteraction { override fun onSeekStarted() = Unit override fun onSeekPositionChanged( newSeekPosition: Duration, ) = Unit override fun onSeekStopped() = Unit }, ) } }