package com.suno.android.ui.screens.share_asset import android.content.ClipData import android.content.Context import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize 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.material3.CenterAlignedTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.suno.android.common_data.use_case.ProcessLyricsForDisplayUseCase import com.suno.android.common_ui.components.LinearProgressIndicator import com.suno.android.common_ui.components.aura.AuraBrush import com.suno.android.common_ui.components.aura.AuraState import com.suno.android.common_ui.components.aura.rememberAuraBrush import com.suno.android.common_ui.components.aura.rememberAuraState import com.suno.android.common_ui.components.buttons.RoundedButton import com.suno.android.common_ui.components.loading.SunoSpinner import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.extensions.collectCurrentPositionAsState import com.suno.android.common_ui.extensions.collectDurationAsState import com.suno.android.common_ui.models.asString import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.SunoTheme import com.suno.android.preview.FakeLyrics import com.suno.android.preview.FakePlayer import com.suno.android.preview.FakeSongListData import com.suno.android.ui.screens.share_asset.components.VideoClipPreview import com.suno.android.ui.screens.share_asset.components.VideoClipPreviewControls import com.suno.android.ui.screens.share_asset.components.VideoClipPreviewWaveform import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import kotlin.random.Random import kotlin.time.Duration 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 fun ShareAssetEditorScreen( vm: ShareAssetEditorScreenVM = hiltViewModel(), navigateBack: () -> Unit, ) { val viewState by vm.state.collectAsState() ShareAssetEditorScreen( viewState = viewState, effects = vm.effects, onEvent = vm::sendEvent, navigateBack = navigateBack, ) } @Composable fun ShareAssetEditorScreen( viewState: ShareAssetEditorScreenState, effects: Flow, onEvent: (ShareAssetEditorScreenEvent) -> Unit, navigateBack: () -> Unit, ) { val context = LocalContext.current val snackbarState = remember { SnackbarHostState() } LaunchedEffect(effects) { effects.collect { effect -> // run each effect handler in its own coroutine launch { handleEffect( effect = effect, context = context, snackbarHostState = snackbarState, ) } } } Scaffold( topBar = { CenterAlignedTopAppBar( title = { ShareAssetEditorTitle( isGenerating = viewState.isGenerating, ) }, navigationIcon = { CircleIconButton( vectorDrawableResId = CommonUiR.drawable.close, contentDescription = stringResource(CommonResR.string.close), onClick = navigateBack, ) }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = ExtendedTheme.colors.backgroundPrimary, ), ) }, snackbarHost = { SnackbarHost( hostState = snackbarState, ) }, ) { padding -> AnimatedContent( targetState = viewState, contentKey = { it::class }, ) { state -> when (state) { is ShareAssetEditorScreenState.FetchingSong -> ShareAssetEditorScreenLoadingContent( viewState = state, modifier = Modifier .fillMaxSize() .padding(padding), ) is ShareAssetEditorScreenState.LoadingError -> ShareAssetEditorScreenErrorContent( viewState = state, onEvent = onEvent, modifier = Modifier .fillMaxSize() .padding(padding) .padding(16.dp), ) is ShareAssetEditorScreenState.Ready -> ShareAssetEditorScreenReadyContent( viewState = state, onEvent = onEvent, modifier = Modifier .fillMaxSize() .padding(padding), ) } } } } @Composable fun ShareAssetEditorTitle( isGenerating: Boolean, modifier: Modifier = Modifier, ) { AnimatedContent( targetState = isGenerating, transitionSpec = { fadeIn() togetherWith fadeOut() using SizeTransform() }, modifier = modifier, ) { currentIsGenerating -> if (currentIsGenerating) { Text( text = stringResource(CommonResR.string.exporting_video), ) } else { Text( text = stringResource(CommonResR.string.create_video_clip_title), ) } } } @Composable private fun ShareAssetEditorScreenLoadingContent( viewState: ShareAssetEditorScreenState.FetchingSong, modifier: Modifier = Modifier, ) { Box( contentAlignment = Alignment.Center, modifier = modifier, ) { SunoSpinner() } } @Composable private fun ShareAssetEditorScreenErrorContent( viewState: ShareAssetEditorScreenState.LoadingError, onEvent: (ShareAssetEditorScreenEvent) -> Unit, modifier: Modifier = Modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier, ) { Text( text = stringResource(CommonResR.string.error_message), textAlign = TextAlign.Center, ) RoundedButton( buttonText = stringResource(CommonResR.string.retry), onClick = { onEvent(ShareAssetEditorScreenEvent.RetrySongLoad) }, modifier = Modifier.fillMaxWidth(), ) } } @Composable private fun ShareAssetEditorScreenReadyContent( viewState: ShareAssetEditorScreenState.Ready, onEvent: (ShareAssetEditorScreenEvent) -> Unit, modifier: Modifier = Modifier, ) { val playingTime by viewState.audioPlayer.collectCurrentPositionAsState() val totalDuration by viewState.audioPlayer.collectDurationAsState() val auraBrush = rememberAuraBrush() val auraState = rememberAuraState() Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, ) { VideoClipPreview( song = viewState.song, lyrics = viewState.lyrics, playingTime = playingTime, totalDuration = totalDuration, clipStartTime = viewState.rangeStartTime, clipEndTime = viewState.endTime, videoPlayer = viewState.videoPlayer, modifier = Modifier.weight(1f), ) AnimatedContent( targetState = viewState.isGenerating, transitionSpec = { val isTransitionToGenerating = targetState if (isTransitionToGenerating) { ContentTransform( targetContentEnter = slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up), initialContentExit = fadeOut() + scaleOut(targetScale = .8f), ) } else { ContentTransform( targetContentEnter = fadeIn() + scaleIn(initialScale = .8f), initialContentExit = slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down), targetContentZIndex = -1f, // show controls appearing behind sliding out generating ) } }, ) { currentState -> if (currentState) { GeneratingSheet( auraBrush = auraBrush, ) } else { EditingControls( viewState = viewState, totalDuration = totalDuration, onEvent = onEvent, auraBrush = auraBrush, auraState = auraState, ) } } } } @Composable private fun GeneratingSheet( auraBrush: AuraBrush, modifier: Modifier = Modifier, ) { Column { Spacer(Modifier.height(20.dp)) Column( modifier = modifier .defaultMinSize(minHeight = 128.dp) .height(intrinsicSize = IntrinsicSize.Min) .background(ExtendedTheme.colors.backgroundSecondary), ) { LinearProgressIndicator( brush = auraBrush.brush, modifier = Modifier .fillMaxWidth() .height(3.dp), ) Column( verticalArrangement = Arrangement.SpaceAround, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxWidth() .weight(1f), ) { Text( text = stringResource(CommonResR.string.generating_share_video_title), style = ExtendedTheme.typography.userTitle, textAlign = TextAlign.Center, ) } } } } @Composable private fun EditingControls( viewState: ShareAssetEditorScreenState.Ready, onEvent: (ShareAssetEditorScreenEvent) -> Unit, totalDuration: Duration, modifier: Modifier = Modifier, auraBrush: AuraBrush = rememberAuraBrush(), auraState: AuraState = rememberAuraState(), ) { Column( modifier = modifier, ) { VideoClipPreviewControls( player = viewState.audioPlayer, rangeStartTime = viewState.rangeStartTime, selectedDurationOption = viewState.selectedRangeDuration, durationOptions = viewState.rangeDurationOptions, onDurationOptionSelected = { onEvent( ShareAssetEditorScreenEvent.SelectDurationOption( it, ), ) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) Spacer(Modifier.height(14.dp)) VideoClipPreviewWaveform( viewState = viewState, totalDuration = totalDuration, onEvent = onEvent, auraBrush = auraBrush, auraState = auraState, ) Spacer(Modifier.height(26.dp)) ShareAssetEditorBottomButton( onClick = { onEvent(ShareAssetEditorScreenEvent.SubmitClick) }, modifier = Modifier .padding(horizontal = 16.dp) .fillMaxWidth(), ) } } @Composable private fun ShareAssetEditorBottomButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { RoundedButton( buttonText = stringResource(CommonResR.string.share), onClick = onClick, modifier = modifier, ) } private suspend fun handleEffect( effect: ShareAssetEditorScreenEffect, context: Context, snackbarHostState: SnackbarHostState, ) = coroutineScope { when (effect) { is ShareAssetEditorScreenEffect.ShareVideoAsset -> { val intent = Intent(Intent.ACTION_SEND).apply { type = "video/*" putExtra(Intent.EXTRA_STREAM, effect.uri) // shows file title flags = Intent.FLAG_GRANT_READ_URI_PERMISSION clipData = ClipData.newRawUri("", effect.uri) } context.startActivity( Intent.createChooser(intent, null), ) } is ShareAssetEditorScreenEffect.ShowError -> { snackbarHostState.showSnackbar( message = effect.text.asString(context.resources), ) } } } @PreviewLightDark @Composable private fun ShareAssetEditorScreen_Editing_Preview() { SunoTheme { ShareAssetEditorScreen( viewState = ShareAssetEditorScreenState.Ready( song = FakeSongListData.cat, waveform = FloatArray(100) { Random.nextFloat() }, lyrics = ProcessLyricsForDisplayUseCase().invoke(FakeLyrics.cat.alignedWords).toImmutableList(), audioPlayer = FakePlayer(), videoPlayer = FakePlayer(), isSeeking = false, rangeStartTime = 5.seconds, ), effects = emptyFlow(), onEvent = {}, navigateBack = {}, ) } } @PreviewLightDark @Composable private fun ShareAssetEditorScreen_Error_Preview() { SunoTheme { ShareAssetEditorScreen( viewState = ShareAssetEditorScreenState.LoadingError, effects = emptyFlow(), onEvent = {}, navigateBack = {}, ) } } @PreviewLightDark @Composable private fun ShareAssetEditorScreen_Generating_Preview() { SunoTheme { ShareAssetEditorScreen( viewState = ShareAssetEditorScreenState.Ready( song = FakeSongListData.cat, waveform = FloatArray(100) { Random.nextFloat() }, lyrics = ProcessLyricsForDisplayUseCase().invoke(FakeLyrics.cat.alignedWords).toImmutableList(), audioPlayer = FakePlayer(), videoPlayer = FakePlayer(), isSeeking = false, rangeStartTime = 5.seconds, generatingState = ShareAssetEditorScreenState.GeneratingState.Generating, ), effects = emptyFlow(), onEvent = {}, navigateBack = {}, ) } }