package com.suno.android.ui.screens.search import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut 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.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.SheetState import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource 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_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.playlists.Playlist import com.suno.android.common_ui.R import com.suno.android.common_ui.components.buttons.SearchTabButton import com.suno.android.common_ui.components.list_items.PlaylistListItem import com.suno.android.common_ui.components.list_items.ProfileListItem import com.suno.android.common_ui.components.list_items.SongListItem import com.suno.android.common_ui.components.loading.LoadingMoreListItem import com.suno.android.common_ui.components.loading.SunoSpinner import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.components.text_fields.SearchBarTextField import com.suno.android.common_ui.effects.OnEndOfListReachedEffect import com.suno.android.common_ui.extensions.xAsFormattedCount import com.suno.android.common_ui.extensions.xBottomPlayerPadding import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.extensions.xAsUiModelBadge import com.suno.android.ui.bottom_sheets.song_actions.SongActionBottomSheetConsumerEvent import com.suno.android.ui.bottom_sheets.song_actions.SongActionsBottomSheet import com.suno.android.ui.screens.create.CreatePromptForm import com.suno.android.ui.screens.create.audio.CreateAudioState import com.suno.android.ui.screens.home.mediaplayer.omniPlayer import com.suno.android.utils.mvi.MviEffectHandler import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR @Composable fun ExploreSearchScreen( vm: ExploreSearchScreenVM = hiltViewModel(), onUserClick: (handle: UserHandle, searchKey: String?) -> Unit, onPlaylistClick: (playlistId: Id, searchKey: String?) -> Unit, onUpgradeClick: () -> Unit, onReusePromptClick: (CreatePromptForm) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, onBackPress: () -> Unit, ) { val viewState by vm.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() var inputVal by remember { mutableStateOf(viewState.query) } val lazyListStates = rememberCategoryLazyListStates() OnEndOfListReachedEffect( listState = lazyListStates[viewState.selectedCategory], ) { vm.sendEvent(ExploreSearchScreenEvent.OnScrollToEndOfList) } // reset lists on search term change var lastSearchQuery by remember { mutableStateOf(viewState.query) } LaunchedEffect(viewState.query) { if (viewState.query != lastSearchQuery) { lastSearchQuery = viewState.query for (state in lazyListStates) { state.scrollToItem(0) } } } var showSongActionContainerBottomSheet: Boolean by remember { mutableStateOf(false) } val songActionsContainerHostState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, // Allow partial expansion, ) LaunchedEffect(showSongActionContainerBottomSheet) { if (showSongActionContainerBottomSheet) { // Sheet is now in composition, animate it songActionsContainerHostState.show() } } // Bidirectional sync for sheet state so it'll animate LaunchedEffect(songActionsContainerHostState.isVisible) { showSongActionContainerBottomSheet = songActionsContainerHostState.isVisible } val snackbarHostState = remember { SnackbarHostState() } val songDeletedMsg = stringResource(id = CommonResR.string.song_deleted) val undoString = stringResource(CommonResR.string.undo) LaunchedEffect(viewState.deletedSong) { if (viewState.deletedSong != null) { val result = snackbarHostState.showSnackbar( message = songDeletedMsg, actionLabel = undoString, duration = SnackbarDuration.Short, ) when (result) { SnackbarResult.ActionPerformed -> { viewState.deletedSong?.let { vm.sendEvent(ExploreSearchScreenEvent.OnUndoDeleteSong(songToUndoDelete = it)) } } SnackbarResult.Dismissed -> { // Do nothing } } } } MviEffectHandler(effects = vm.effects) { effect -> when (effect) { is ExploreSearchScreenEffect.ShowSnackbar -> { if (effect.message.isNotEmpty()) { snackbarHostState.showSnackbar(effect.message) } } is ExploreSearchScreenEffect.NavigateToCreatorProfile -> { onUserClick(effect.userHandle, effect.searchKey) } is ExploreSearchScreenEffect.NavigateToPlaylist -> { onPlaylistClick(effect.playlistId, effect.searchKey) } } } fun updateSearchQuery( query: String, ) { inputVal = query vm.sendEvent(ExploreSearchScreenEvent.OnUpdateSearchQuery(query = query)) } Scaffold( contentWindowInsets = WindowInsets.systemBars, modifier = Modifier .background(ExtendedTheme.colors.backgroundPrimary) .fillMaxSize(), topBar = { ExploreSearchTopBar( searchQuery = inputVal, updateSearchQuery = ::updateSearchQuery, placeholderText = viewState.selectedCategory.placeholderText(), onBackPressed = onBackPress, emitOnSearch = { vm.sendEvent(it) }, modifier = Modifier.windowInsetsPadding( WindowInsets.systemBars.only( WindowInsetsSides.Horizontal + WindowInsetsSides.Top, ), ), ) }, snackbarHost = { SnackbarHost( hostState = snackbarHostState, modifier = Modifier.xBottomPlayerPadding(vm.isPlayerVisible()), ) }, ) { innerPadding -> Column( modifier = Modifier .background(ExtendedTheme.colors.backgroundPrimary) .padding(innerPadding) .fillMaxSize(), ) { val categories = persistentListOf( ExploreSearchScreenState.Category.Songs, ExploreSearchScreenState.Category.Creators, ExploreSearchScreenState.Category.Playlists, ) ExploreSearchTabRow( categories = categories, selectedCategory = viewState.selectedCategory, onCategorySelected = { if (it == viewState.selectedCategory) { scope.launch { lazyListStates[it].animateScrollToItem(0) } } vm.sendEvent(ExploreSearchScreenEvent.OnSelectSearchCategory(it)) }, ) ListContent( lazyListStates = lazyListStates, categories = categories, viewState = viewState, onSongClick = { song -> vm.sendEvent(ExploreSearchScreenEvent.SearchResult.OnSongClicked(song)) }, onOverflowClick = { id -> vm.sendEvent( ExploreSearchScreenEvent.OnSongOverflowOpened( songId = id, ), ) showSongActionContainerBottomSheet = true }, onUserClick = { handle -> vm.sendEvent(ExploreSearchScreenEvent.SearchResult.OnCreatorClicked(handle)) }, onPlaylistClick = { playlistId -> vm.sendEvent(ExploreSearchScreenEvent.SearchResult.OnPlaylistClicked(playlistId)) }, modifier = Modifier.fillMaxSize(), ) if (showSongActionContainerBottomSheet) { val currentlySelectedSong = viewState.songResults.items.find { it.id == viewState.songIdToOperate } currentlySelectedSong?.let { SongActionsBottomSheet( song = currentlySelectedSong, canRemoveFromPlaylist = false, isShowShareVideoGateEnabled = viewState.isShowShareVideoGateEnabled, songActionBottomSheetConsumerEvents = { event -> when (event) { SongActionBottomSheetConsumerEvent.DismissSongActionsContainerHost -> { scope.launch { songActionsContainerHostState.hide() } } is SongActionBottomSheetConsumerEvent.NavigateToRemix -> { scope.launch { songActionsContainerHostState.hide() showSongActionContainerBottomSheet = false } navigateToRemix(event.songId, event.task) } is SongActionBottomSheetConsumerEvent.NavigateToShareVideo -> { navigateToAssetEdit(event.songId) } is SongActionBottomSheetConsumerEvent.OnRemixClicked -> { Unit } is SongActionBottomSheetConsumerEvent.OnReusePromptClicked -> { onReusePromptClick.invoke(event.formData) } is SongActionBottomSheetConsumerEvent.OnShowSnackbar -> { vm.triggerSnackbar(message = event.message) } is SongActionBottomSheetConsumerEvent.OnSongDeleted -> { vm.sendEvent(ExploreSearchScreenEvent.OnSongDeleted(deletedSong = event.song)) } is SongActionBottomSheetConsumerEvent.OnSongRenamed -> { vm.sendEvent(ExploreSearchScreenEvent.OnSongRenamed(renamedSong = event.song)) } is SongActionBottomSheetConsumerEvent.OnSongReported -> { scope.launch { snackbarHostState.showSnackbar( message = event.message, ) } } is SongActionBottomSheetConsumerEvent.OnSongUndoDeleted -> Unit is SongActionBottomSheetConsumerEvent.OnSongUndoReported -> Unit SongActionBottomSheetConsumerEvent.OnUpgradeClicked -> { onUpgradeClick.invoke() } is SongActionBottomSheetConsumerEvent.OnRemoveFromPlaylistTapped -> Unit } }, ) } } } } } @Composable private fun ExploreSearchTopBar( searchQuery: String, updateSearchQuery: (String) -> Unit, placeholderText: String, onBackPressed: () -> Unit, emitOnSearch: (ExploreSearchScreenEvent.OnUpdateSearchQuery) -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current val focusRequester = remember { FocusRequester() } Row( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { CircleIconButton( onClick = { onBackPressed.invoke() }, vectorDrawableResId = R.drawable.arrow_left, contentDescription = stringResource(CommonResR.string.back), ) SearchBarTextField( value = searchQuery, placeholder = placeholderText, onValueChange = updateSearchQuery, onSearchImeAction = { emitOnSearch(ExploreSearchScreenEvent.OnUpdateSearchQuery(searchQuery)) focusManager.clearFocus() }, modifier = Modifier .weight(1f) .focusRequester(focusRequester), ) // automatically focus on search field and pop keyboard LaunchedEffect(focusRequester) { focusRequester.requestFocus() } } } @Composable private fun ExploreSearchScreenState.Category.name() = when (this) { ExploreSearchScreenState.Category.Songs -> stringResource(CommonResR.string.songs) ExploreSearchScreenState.Category.Creators -> stringResource(CommonResR.string.creator) ExploreSearchScreenState.Category.Playlists -> stringResource(CommonResR.string.playlists) } @Composable private fun ExploreSearchScreenState.Category.placeholderText() = when (this) { ExploreSearchScreenState.Category.Songs -> stringResource(CommonResR.string.search_explore_songs_placeholder) ExploreSearchScreenState.Category.Creators -> stringResource(CommonResR.string.search_explore_creators_placeholder) ExploreSearchScreenState.Category.Playlists -> stringResource(CommonResR.string.search_explore_playlists_placeholder) } @Composable private fun ExploreSearchTabRow( categories: ImmutableList, selectedCategory: ExploreSearchScreenState.Category, onCategorySelected: (ExploreSearchScreenState.Category) -> Unit, modifier: Modifier = Modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), ) { categories.forEach { category -> key(category) { SearchTabButton( text = category.name(), selected = selectedCategory == category, onClick = { onCategorySelected.invoke(category) }, modifier = Modifier.weight(1f), ) } } } } private data class ListContentState( val category: ExploreSearchScreenState.Category, val isLoading: Boolean, ) private class CategoryLazyListStates( private val songs: LazyListState = LazyListState(), private val users: LazyListState = LazyListState(), private val playlists: LazyListState = LazyListState(), ) { operator fun get( category: ExploreSearchScreenState.Category, ) = when (category) { ExploreSearchScreenState.Category.Songs -> songs ExploreSearchScreenState.Category.Creators -> users ExploreSearchScreenState.Category.Playlists -> playlists } operator fun iterator() = listOf(songs, users, playlists).iterator() } @Composable private fun rememberCategoryLazyListStates(): CategoryLazyListStates { val songs = rememberLazyListState() val users = rememberLazyListState() val playlists = rememberLazyListState() return remember { CategoryLazyListStates( songs = songs, users = users, playlists = playlists, ) } } @Composable private fun ListContent( categories: ImmutableList, viewState: ExploreSearchScreenState, onSongClick: (SongListData) -> Unit, onOverflowClick: (songId: Id) -> Unit, onUserClick: (handle: UserHandle) -> Unit, onPlaylistClick: (playlistId: Id) -> Unit, modifier: Modifier = Modifier, lazyListStates: CategoryLazyListStates = rememberCategoryLazyListStates(), ) { val songResults = viewState.songResults.items val userResults = viewState.userResults.items val playlistResults = viewState.playlistResults.items val listContentState = ListContentState( category = viewState.selectedCategory, isLoading = viewState.getLoadingStateForCategory(viewState.selectedCategory) == ExploreSearchScreenState.LoadingState.LoadingPage, ) val categoryIndexLookup = remember(categories) { categories.withIndex().associate { it.value to it.index }.toMap() } val windowInsets = WindowInsets.ime.union(WindowInsets.omniPlayer) AnimatedContent( targetState = listContentState, transitionSpec = { // lock in types for smart casting val initial = initialState val target = targetState val initialIndex = categoryIndexLookup[initial.category] val targetIndex = categoryIndexLookup[target.category] when { targetIndex == null || initialIndex == null -> fadeIn() togetherWith fadeOut() targetIndex > initialIndex -> slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.Start, ) togetherWith slideOutOfContainer( AnimatedContentTransitionScope.SlideDirection.Start, ) targetIndex < initialIndex -> slideIntoContainer( AnimatedContentTransitionScope.SlideDirection.End, ) togetherWith slideOutOfContainer( AnimatedContentTransitionScope.SlideDirection.End, ) else -> fadeIn() togetherWith fadeOut() } }, modifier = modifier, ) { currentState -> if (currentState.isLoading) { Box( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxSize() .windowInsetsPadding(windowInsets), ) { SunoSpinner() } } else { LazyColumn( contentPadding = windowInsets.asPaddingValues(), state = lazyListStates[currentState.category], modifier = Modifier.consumeWindowInsets(windowInsets), ) { if (viewState.isDisplayingPopular) { item { val headingText = when (currentState.category) { ExploreSearchScreenState.Category.Songs -> { stringResource(CommonResR.string.popular_songs) } ExploreSearchScreenState.Category.Creators -> { stringResource(CommonResR.string.popular_creators) } ExploreSearchScreenState.Category.Playlists -> { stringResource(CommonResR.string.popular_playlists) } } Text( text = headingText, style = ExtendedTheme.typography.introCopy, color = ExtendedTheme.colors.textPrimary, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), ) } } when (currentState.category) { ExploreSearchScreenState.Category.Songs -> { items(songResults) { songResult -> SongListItem( showStats = true, clipImageUrl = songResult.displayImageUrl, songTitle = songResult.title?.takeIf { it.isNotEmpty() } ?: stringResource(id = CommonResR.string.untitled), songTags = songResult.tags, playCount = songResult.playCount?.xAsFormattedCount(), upvoteCount = songResult.upvoteCount?.xAsFormattedCount(), artistName = songResult.handle?.handle, modelBadge = songResult.modelBadge?.xAsUiModelBadge(), onClick = { onSongClick(songResult) }, onOverflowClick = { onOverflowClick(songResult.id) }, onUserClick = { songResult.handle?.let(onUserClick) }, isPreviewClip = songResult.isPreview, ) HorizontalDivider( thickness = 1.dp, color = ExtendedTheme.colors.borderPrimary, ) } } ExploreSearchScreenState.Category.Creators -> { items(userResults) { userResult -> ProfileListItem( handle = userResult.handle ?: "", username = userResult.displayName ?: "", avatarImageUrl = userResult.avatarImageUrl ?: "", followersCount = userResult.stats?.followersCount?.xAsFormattedCount() ?: "", onClick = { userResult.handle.let { it?.let { onUserClick(UserHandle(it)) } } }, ) HorizontalDivider( thickness = 1.dp, color = ExtendedTheme.colors.borderPrimary, ) } } ExploreSearchScreenState.Category.Playlists -> { items(playlistResults) { playlistResult -> PlaylistListItem( playlistName = playlistResult.name ?: "", playlistImageUrl = playlistResult.imageUrl ?: "", playlistSongCount = playlistResult.numTotalResults?.xAsFormattedCount(), artistName = playlistResult.userDisplayName, onClick = { playlistResult.id?.let { onPlaylistClick.invoke(Id(it)) } }, onUserClick = { playlistResult.userHandle.let { it?.let { onUserClick.invoke( UserHandle(it), ) } } }, ) HorizontalDivider( thickness = 1.dp, color = ExtendedTheme.colors.borderPrimary, ) } } } val isLoadingMore = viewState.getLoadingStateForCategory(currentState.category) == ExploreSearchScreenState.LoadingState.LoadingMore if (isLoadingMore) { item { LoadingMoreListItem() } } } } } }