package com.suno.android.ui.screens.orpheus import android.content.ClipDescription import android.content.Intent import androidx.annotation.FloatRange import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.DrawerValue import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect 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.saveable.rememberSaveable 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.suno.android.common_core_utils.Id import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_data.repos.orpheus.models.OrpheusMessage import com.suno.android.common_ui.R import com.suno.android.common_ui.components.AnimatedAsyncDataContent import com.suno.android.common_ui.components.aura.AuraStyle import com.suno.android.common_ui.components.aura.auraBackground import com.suno.android.common_ui.components.bottom_sheet.ShareLinkBottomSheet import com.suno.android.common_ui.components.bottom_sheet.SharePlatformConstants import com.suno.android.common_ui.components.bottom_sheet.StateBottomSheet import com.suno.android.common_ui.components.bottom_sheet.handleShareClicked import com.suno.android.common_ui.components.buttons.AuraButton import com.suno.android.common_ui.components.drawer.DrawerSide import com.suno.android.common_ui.components.drawer.OptionalModalDrawer import com.suno.android.common_ui.extensions.plus import com.suno.android.common_ui.extensions.windowInsetsPadding import com.suno.android.common_ui.models.asString import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.ui.bottom_sheets.create.CollapsableSheetScaffold import com.suno.android.ui.bottom_sheets.create.CollapsableSheetValue import com.suno.android.ui.bottom_sheets.create.createBottomButtons import com.suno.android.ui.bottom_sheets.create.rememberCollapsableSheetState import com.suno.android.ui.bottom_sheets.song_actions.SongActionBottomSheetConsumerEvent import com.suno.android.ui.bottom_sheets.song_actions.SongActionsTray import com.suno.android.ui.effects.SystemBarsEffect import com.suno.android.ui.screens.create.CreatePromptForm import com.suno.android.ui.screens.create.audio.CreateAudioState import com.suno.android.ui.screens.create.text.CreateTextEffect import com.suno.android.ui.screens.create.text.CreateTextEvent import com.suno.android.ui.screens.create.text.CreateTextScreen import com.suno.android.ui.screens.create.text.CreateTextScreenVM import com.suno.android.ui.screens.create.text.CreateTextState import com.suno.android.ui.screens.orpheus.components.ChatInput import com.suno.android.ui.screens.orpheus.components.ChatInputActionSheet import com.suno.android.ui.screens.orpheus.components.WelcomeMessage import com.suno.android.ui.screens.orpheus.components.message.MessageBubble import com.suno.android.ui.screens.orpheus.components.message.WaitingForResponseIndicator import com.suno.android.ui.screens.orpheus.components.projects.OrpheusWorkspacesContent import com.suno.android.ui.screens.orpheus.components.projects.WorkspaceContentDefaults import com.suno.android.ui.screens.orpheus.components.projects.WorkspaceMoreActionsItem import com.suno.android.utils.mvi.MviEffectHandler import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR @Composable fun OrpheusChatScreen( onBackClick: () -> Unit, onUpgradeClicked: () -> Unit, onReusePromptClicked: (CreatePromptForm) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, modifier: Modifier = Modifier, vm: OrpheusChatVM = hiltViewModel(), ) { val chatState by vm.state.collectAsStateWithLifecycle() val listState = rememberLazyListState() val collapsableSheetState = rememberCollapsableSheetState() val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current val snackbarHostState = remember { SnackbarHostState() } val createTextVM = hiltViewModel() val createTextState by createTextVM.state.collectAsStateWithLifecycle() val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val drawerState = rememberDrawerState(DrawerValue.Closed) val resources = LocalResources.current val chatInputFocusRequester = remember { FocusRequester() } val auraStyle = remember { AuraStyle( colors = AuraStyle.Colors( a = Color(red = 0.192f * 0.5f, green = 0.0f, blue = 0.259f * 0.5f), // coldColor - 50% intensity b = Color( red = 0.631f * 0.5f, green = 0.255f * 0.5f, blue = 0.067f * 0.5f, ), // warmColor - 50% intensity c = Color(red = 0.086f * 0.5f, green = 0.0f, blue = 0.235f * 0.5f), // hotColor - 50% intensity ), points = AuraStyle.Points( start = -0.3f, // Much smoother blend from edges backgroundA = 0.0f, // Very gradual warmColor introduction ab = 0.4f, // Spread out warmColor prominence bc = 0.6f, // Gradual transition to hotColor ), scales = AuraStyle.Scales( fractal = 0.3f, grain = 0.0f, ), ) } SystemBarsEffect( themeMode = chatState.themeMode, forceDarkMode = chatState.isEmptyState, ) // Track screen lifecycle DisposableEffect(Unit) { vm.sendEvent(OrpheusChatEvent.ScreenOpened) onDispose { vm.sendEvent(OrpheusChatEvent.ScreenClosed) } } TrackOrpheusAndCustomModeChanges(collapsableSheetState.anchoredDraggableState.currentValue) { mode -> when (mode) { OrpheusMode.Orpheus -> vm.sendEvent(OrpheusChatEvent.SwitchedToOrpheusMode) OrpheusMode.Custom -> vm.sendEvent(OrpheusChatEvent.SwitchedToCustomMode) } } MviEffectHandler(effects = createTextVM.effects) { effect -> if (effect is CreateTextEffect.CreateSubmissionComplete) { coroutineScope.launch { collapsableSheetState.collapse() } if (effect.messageId != null) { vm.sendEvent( OrpheusChatEvent.AddGeneratedClipsToMessage( messageId = effect.messageId, clipIds = effect.clipIds, ), ) } else { vm.sendEvent( OrpheusChatEvent.CreateNewMessageWithGeneratedClips( clipIds = effect.clipIds, ), ) } } } MviEffectHandler(effects = vm.effects) { effect -> when (effect) { is OrpheusChatEffect.ShareLink -> { handleShareClicked( platform = effect.sharePlatform, shareLink = effect.link.url, context = context, clipboardManager = clipboardManager, ) } OrpheusChatEffect.CloseProjectsDrawer -> drawerState.close() is OrpheusChatEffect.ShareChatLink -> { val shareIntent = Intent().apply { action = Intent.ACTION_SEND type = ClipDescription.MIMETYPE_TEXT_PLAIN putExtra(Intent.EXTRA_TEXT, effect.link.url) } val chooser = Intent.createChooser( shareIntent, resources.getString(CommonResR.string.orpheus_project_share_sheet_title), ) context.startActivity(chooser) } is OrpheusChatEffect.ShowSnackbar -> snackbarHostState.showSnackbar( message = effect.message.asString(resources), ) } } ChatAutoScrollEffect( listState = listState, messages = chatState.messages.dataOrNull ?: persistentListOf(), ) LaunchedEffect(chatState.selectedMessageClip?.clip?.clipId) { chatState.selectedMessageClip?.let { createTextVM.sendEvent( CreateTextEvent.OnLoadClipFromOrpheusChat( messageId = it.messageId, clip = it.clip, ), ) } ?: createTextVM.sendEvent(CreateTextEvent.OnResetCreateScreen) } // Reset custom mode screen when starting new chat LaunchedEffect(chatState.messages.dataOrNull?.size) { if (chatState.messages.dataOrNull.isNullOrEmpty()) { createTextVM.sendEvent(CreateTextEvent.OnResetCreateScreen) } } LaunchedEffect(createTextState.audioState) { when (val createAudioState = createTextState.audioState) { is CreateAudioState.None -> { vm.sendEvent(OrpheusChatEvent.UnselectClip) } is CreateAudioState.Completed -> { if (chatState.selectedMessageClip?.clip?.clipId != createAudioState.clip.clipId) { vm.sendEvent( OrpheusChatEvent.SelectClip( messageId = null, clip = createAudioState.clip, ), ) } } else -> Unit } } LaunchedEffect(chatState.isEmptyState) { if (chatState.isEmptyState) { chatInputFocusRequester.requestFocus() } } Scaffold( contentWindowInsets = WindowInsets(), snackbarHost = { SnackbarHost( hostState = snackbarHostState, ) }, ) { paddingValues -> OptionalModalDrawer( enabled = chatState.isProjectsEnabled, drawerState = drawerState, gesturesEnabled = !drawerState.isClosed, drawerContent = { BoxWithConstraints { Column( modifier = Modifier .fillMaxHeight() .width(325.dp.coerceAtMost(maxWidth - 64.dp)), ) { OrpheusWorkspacesContent( controller = vm.projectsController, modifier = Modifier.background(ExtendedTheme.colors.backgroundPrimary), itemMoreActionsContent = { project, dismiss -> WorkspaceMoreActionsItem( iconPainter = painterResource(R.drawable.share_android), text = stringResource(CommonResR.string.share), onClick = { vm.sendEvent(OrpheusChatEvent.ShareProject(project)) dismiss() }, ) val defaults = WorkspaceContentDefaults.itemMoreActionsContent { event -> vm.projectsController.sendEvent(event) } defaults(project, dismiss) }, ) } } }, drawerSide = DrawerSide.End, modifier = Modifier.padding(paddingValues), ) { val baseAlpha by animateFloatAsState( targetValue = if (chatState.isEmptyState) 0.9f else 1f, label = "Sheet alpha", ) CollapsableSheetScaffold( modifier = modifier.pointerInput(Unit) { detectVerticalDragGestures { _, dragAmount -> if (dragAmount > 0) { focusManager.clearFocus() } } }, state = collapsableSheetState, windowInsets = WindowInsets.safeDrawing, sheetColor = { progress -> val progressDelta = if (chatState.isEmptyState) progress * 0.1f else 0f ExtendedTheme.colors.backgroundSecondary.copy(alpha = baseAlpha + progressDelta) }, sharedBottom = { expandedPercent, _ -> Box { if (expandedPercent != 1f) { ChatInput( message = chatState.currentMessage, onInputChange = { vm.sendEvent(OrpheusChatEvent.MessageChanged(it)) }, onSendClick = { vm.sendEvent(OrpheusChatEvent.SendMessage) }, onPlusClick = { vm.sendEvent(OrpheusChatEvent.ShowChatInputOptions) }, onCustomModeClick = { coroutineScope.launch { collapsableSheetState.expand() } vm.sendEvent(OrpheusChatEvent.SwitchedToCustomMode) }, modifier = Modifier .alpha(1f - expandedPercent) .focusRequester(chatInputFocusRequester), contentPadding = PaddingValues( start = 16.dp, end = 16.dp, bottom = 16.dp, ), ) } } }, sheetContent = { expandedPercent -> CreateTextScreen( vm = createTextVM, modifier = Modifier.alpha(expandedPercent), showBottomBar = false, onCreateClick = { createTextVM.sendEvent(CreateTextEvent.OnGenerateClicked) }, onPlusClick = { vm.sendEvent(OrpheusChatEvent.ShowChatInputOptions) }, isFromOrpheus = true, ) }, content = { expandedPercent -> LaunchedEffect(chatState.isEmptyState, expandedPercent) { if (!chatState.isEmptyState && (expandedPercent == 0f || expandedPercent == 1f)) { focusManager.clearFocus() } } Box(modifier = Modifier.fillMaxSize()) { if (chatState.isEmptyState) { Box( modifier = Modifier.fillMaxSize().auraBackground(style = auraStyle), ) } Scaffold( containerColor = if (chatState.isEmptyState) { Color.Transparent } else { ExtendedTheme.colors.backgroundPrimary }, contentWindowInsets = WindowInsets.safeDrawing.add(WindowInsets.createBottomButtons), topBar = { OrpheusChatTopBar( chatState = chatState, onBackClick = onBackClick, onNewChatClick = { vm.sendEvent(OrpheusChatEvent.NewChat) }, onWorkspacesClick = { coroutineScope.launch { drawerState.open() } }, ) }, modifier = Modifier.fillMaxSize(), ) { contentPadding -> AnimatedAsyncDataContent( targetState = chatState.messages, modifier = Modifier.fillMaxSize().pointerInput(Unit) { detectTapGestures(onTap = { focusManager.clearFocus() }) }, ) { messages -> if (messages.isEmpty()) { WelcomeMessage( modifier = Modifier.windowInsetsPadding(contentPadding), ) } else { LazyColumn( state = listState, contentPadding = contentPadding + PaddingValues(bottom = 8.dp), modifier = Modifier.fillMaxSize(), ) { items( items = messages, key = { message -> message.messageId.value }, ) { message -> if (message.content.isNotBlank() || message.generatedClips.isNotEmpty()) { MessageBubble( message = message, onClipAction = { vm.sendEvent(it.toVmEvent(messageId = message.messageId)) }, onCreateMoreClipsClick = { vm.sendEvent( OrpheusChatEvent.CreateMoreClips(message = message), ) }, isCreatingMoreClips = chatState.createMoreClipsMessageId == message.messageId, modifier = Modifier.fillMaxWidth(), selectedMessageClip = chatState.selectedMessageClip, ) } } if (chatState.isWaitingForResponse) { item { WaitingForResponseIndicator( modifier = Modifier.fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), ) } } } } } } } }, ) } } OrpheusChatBottomSheets( state = chatState.bottomSheetState, onDismissRequest = { vm.sendEvent(OrpheusChatEvent.DismissBottomSheet) }, snackbarHostState = snackbarHostState, onUpgradeClicked = onUpgradeClicked, onReusePromptClicked = onReusePromptClicked, navigateToAssetEdit = navigateToAssetEdit, navigateToRemix = navigateToRemix, onSongDeleted = { messageId, song -> vm.sendEvent(OrpheusChatEvent.SongDeleted(messageId = messageId, song = song)) }, onSongRenamed = { messageId, song -> vm.sendEvent(OrpheusChatEvent.SongRenamed(messageId = messageId, song = song)) }, onStartShare = { messageId, clip, platform -> vm.sendEvent(OrpheusChatEvent.StartShare(messageId = messageId, clip = clip, platform = platform)) }, createTextVM = createTextVM, createTextState = createTextState, ) } @Composable private fun OrpheusChatBottomSheets( state: OrpheusChatState.BottomSheetState, onDismissRequest: () -> Unit, snackbarHostState: SnackbarHostState, onUpgradeClicked: () -> Unit, onReusePromptClicked: (CreatePromptForm) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, onSongDeleted: (messageId: Id, song: SongListData) -> Unit, onSongRenamed: (messageId: Id, song: SongListData) -> Unit, onStartShare: (messageId: Id, clip: LocalClipData, platform: SharePlatformConstants) -> Unit, createTextVM: CreateTextScreenVM, createTextState: CreateTextState, modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() StateBottomSheet( state = state, isShowing = { it != OrpheusChatState.BottomSheetState.Hidden }, showHandle = { it is OrpheusChatState.BottomSheetState.HasHandle }, onDismissRequest = onDismissRequest, onBack = onDismissRequest, modifier = modifier.statusBarsPadding(), containerColor = ExtendedTheme.colors.backgroundSecondary, ) { currentState -> when (currentState) { OrpheusChatState.BottomSheetState.Hidden -> { // Empty state } is OrpheusChatState.BottomSheetState.SongActionsVisible -> { currentState.clip.xAsSongListDataOrNull()?.let { song -> val songActionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) SongActionsTray( sheetState = songActionsSheetState, song = song, canRemoveFromPlaylist = false, songActionBottomSheetConsumerEvents = { event -> when (event) { SongActionBottomSheetConsumerEvent.DismissSongActionsContainerHost -> { onDismissRequest() } is SongActionBottomSheetConsumerEvent.NavigateToRemix -> { navigateToRemix(event.songId, event.task) } is SongActionBottomSheetConsumerEvent.NavigateToShareVideo -> { navigateToAssetEdit(event.songId) } is SongActionBottomSheetConsumerEvent.OnRemixClicked -> Unit is SongActionBottomSheetConsumerEvent.OnReusePromptClicked -> { onReusePromptClicked(event.formData) } is SongActionBottomSheetConsumerEvent.OnShowSnackbar -> { coroutineScope.launch { snackbarHostState.showSnackbar(event.message) } } is SongActionBottomSheetConsumerEvent.OnSongDeleted -> { onSongDeleted(currentState.messageId, event.song) } is SongActionBottomSheetConsumerEvent.OnSongRenamed -> { onSongRenamed(currentState.messageId, event.song) } is SongActionBottomSheetConsumerEvent.OnSongReported -> { coroutineScope.launch { snackbarHostState.showSnackbar(event.message) } } is SongActionBottomSheetConsumerEvent.OnSongUndoDeleted -> Unit is SongActionBottomSheetConsumerEvent.OnSongUndoReported -> Unit SongActionBottomSheetConsumerEvent.OnUpgradeClicked -> { onUpgradeClicked() } is SongActionBottomSheetConsumerEvent.OnRemoveFromPlaylistTapped -> Unit } }, ) } } is OrpheusChatState.BottomSheetState.ShareLinkVisible -> { ShareLinkBottomSheet( onDismiss = onDismissRequest, onShareClicked = { platform -> onStartShare(currentState.messageId, currentState.clip, platform) }, ) } OrpheusChatState.BottomSheetState.ChatInputOptionsVisible -> { ChatInputActionSheet( onDismiss = onDismissRequest, onModelClick = { modelName -> createTextVM.sendEvent(CreateTextEvent.OnAttemptToSelectModel(modelName)) }, selectedModel = createTextState.activeModel?.name, availableModels = createTextState.availableModels, ) } } } } @Composable private fun CreateButton( enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { AuraButton( disabled = !enabled, onClick = onClick, modifier = modifier, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Icon( imageVector = ImageVector.vectorResource( id = R.drawable.create, ), contentDescription = null, ) Text( text = stringResource(CommonResR.string.create), style = ExtendedTheme.typography.largeSubtitle, textAlign = TextAlign.Center, ) } } } internal sealed interface OrpheusClipAction { val clip: LocalClipData data class OnToggleClipPlayback( override val clip: LocalClipData, ) : OrpheusClipAction data class OnSelectClipClick( override val clip: LocalClipData, ) : OrpheusClipAction data class OnLikeClipClick( override val clip: LocalClipData, ) : OrpheusClipAction data class OnDislikeClipClick( override val clip: LocalClipData, ) : OrpheusClipAction data class OnShareClipClick( override val clip: LocalClipData, ) : OrpheusClipAction data class OnClipOverflowClick( override val clip: LocalClipData, ) : OrpheusClipAction data class OnScrubClip( override val clip: LocalClipData, @FloatRange(from = 0.0, to = 1.0) val scrubPercent: Float, ) : OrpheusClipAction fun toVmEvent( messageId: Id, ): OrpheusChatEvent = when (this) { is OnClipOverflowClick -> OrpheusChatEvent.OpenClipOverflow(messageId = messageId, clip = clip) is OnDislikeClipClick -> OrpheusChatEvent.DislikeClip(messageId = messageId, clip = clip) is OnLikeClipClick -> OrpheusChatEvent.LikeClip(messageId = messageId, clip = clip) is OnSelectClipClick -> OrpheusChatEvent.SelectClip(messageId = messageId, clip = clip) is OnShareClipClick -> OrpheusChatEvent.ShareClip(messageId = messageId, clip = clip) is OnToggleClipPlayback -> OrpheusChatEvent.ToggleClipPlayback(messageId = messageId, clip = clip) is OnScrubClip -> OrpheusChatEvent.ScrubClip( messageId = messageId, clip = clip, scrubPercent = scrubPercent, ) } } private enum class OrpheusMode { Orpheus, Custom } @Composable private fun TrackOrpheusAndCustomModeChanges( sheetValue: CollapsableSheetValue, onModeChanged: (OrpheusMode) -> Unit, ) { var previousMode by rememberSaveable { mutableStateOf(null) } LaunchedEffect(sheetValue) { val currentMode = when (sheetValue) { CollapsableSheetValue.Collapsed -> OrpheusMode.Orpheus CollapsableSheetValue.Expanded -> OrpheusMode.Custom } if (previousMode != null && previousMode != currentMode) { onModeChanged(currentMode) } previousMode = currentMode } }