package com.suno.android.ui.screens.home.library 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.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets 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.height import androidx.compose.foundation.layout.padding 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon 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.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll 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.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.SongListData import com.suno.android.common_ui.R import com.suno.android.common_ui.components.LibraryTopClickableItem import com.suno.android.common_ui.components.bottom_sheet.SingleSelectionBottomSheet import com.suno.android.common_ui.components.infos.empty_states.NoLibrarySongsEmptyState import com.suno.android.common_ui.components.infos.empty_states.NoLikedSongsEmptyState import com.suno.android.common_ui.components.list_items.GeneratingSongListItem import com.suno.android.common_ui.components.list_items.SongListItem import com.suno.android.common_ui.components.loading.SunoPullToRefreshBox 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.xAsFormattedCount import com.suno.android.common_ui.extensions.xBottomPlayerPadding 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.extensions.xAsUiModelBadge import com.suno.android.preview.FakeSongListData 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR @Composable fun LibraryScreen( vm: LibraryScreenVM = hiltViewModel(), onLikedPlaylistClicked: () -> Unit, onPlaylistsClicked: () -> Unit, onUpgradeClicked: () -> Unit, onReusePromptClicked: (CreatePromptForm) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, onSearchClicked: () -> Unit, ) { val viewState = vm.state.collectAsStateWithLifecycle().value LibraryScreen( viewState = viewState, onEvent = { event -> vm.sendEvent(event) }, effects = vm.effects, onLikedPlaylistClicked = onLikedPlaylistClicked, onPlaylistsClicked = onPlaylistsClicked, onUpgradeClicked = onUpgradeClicked, onReusePromptClicked = onReusePromptClicked, navigateToAssetEdit = navigateToAssetEdit, onSearchClicked = onSearchClicked, isPlayerVisible = { vm.isPlayerVisible() }, navigateToRemix = navigateToRemix, ) } @Composable private fun LibraryScreen( viewState: LibraryScreenState, onEvent: (LibraryScreenEvent) -> Unit, effects: Flow, onLikedPlaylistClicked: () -> Unit, onPlaylistsClicked: () -> Unit, onUpgradeClicked: () -> Unit, onReusePromptClicked: (CreatePromptForm) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, onSearchClicked: () -> Unit, isPlayerVisible: () -> Boolean = { false }, ) { val resources = LocalResources.current val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val lazyListState = rememberLazyListState() val songActionsContainerHostState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, // Allow partial expansion, ) val mySongsFilterSheetState = rememberModalBottomSheetState( skipPartiallyExpanded = false, // Allow partial expansion, ) val snackbarHostState = remember { SnackbarHostState() } var showSongActionContainerBottomSheet: Boolean by remember { mutableStateOf(false) } var showMySongsFilterBottomSheet by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() // We need this launched effect for the initial setting of the modal sheet state so it'll animate properly for // FIRST launch 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 endOfListReached: Boolean by remember { derivedStateOf { !lazyListState.canScrollForward } } // We need this launched effect for the initial setting of the modal sheet state so it'll animate properly for // FIRST launch LaunchedEffect(showMySongsFilterBottomSheet) { if (showMySongsFilterBottomSheet) { // Sheet is now in composition, animate it mySongsFilterSheetState.show() } } // Bidirectional sync for sheet state so it'll animate LaunchedEffect(mySongsFilterSheetState.isVisible) { showMySongsFilterBottomSheet = mySongsFilterSheetState.isVisible } LaunchedEffect(endOfListReached) { // do your stuff if (endOfListReached) { when (viewState.viewingContent) { ViewingContent.MySongs -> { onEvent(LibraryScreenEvent.OnFetchNextPage) } ViewingContent.LikedSongs -> { onEvent(LibraryScreenEvent.OnFetchNextPageForLikedSongs) } } } } LaunchedEffect(Unit) { effects.collect { effect -> when (effect) { is LibraryScreenEffect.ShowSnackbar -> { val message = effect.type.message.asString(resources) val actionLabel = effect.type.actionLabel?.asString(resources) val result = snackbarHostState.showSnackbar( message = message, actionLabel = actionLabel, duration = SnackbarDuration.Short, ) if (result == SnackbarResult.ActionPerformed) { onEvent(LibraryScreenEvent.OnSnackbarAction(effect.type)) } } } } } val allFilter = stringResource(CommonResR.string.all_) val privateFilter = stringResource(CommonResR.string.private_) val publicFilter = stringResource(CommonResR.string.public_) val likedFilter = stringResource(CommonResR.string.liked) var activeSongFilter by remember { mutableStateOf(allFilter) } Scaffold( modifier = Modifier .background(ExtendedTheme.colors.backgroundPrimary) .fillMaxSize() .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), topBar = { TopAppBar( scrollBehavior = topAppBarScrollBehavior, colors = TopAppBarDefaults.topAppBarColors( containerColor = ExtendedTheme.colors.backgroundPrimary, scrolledContainerColor = ExtendedTheme.colors.backgroundPrimary, ), title = { Text( text = stringResource(id = CommonResR.string.library), style = ExtendedTheme.typography.smallTrackName, ) }, actions = { CircleIconButton( modifier = Modifier.padding(horizontal = 8.dp), vectorDrawableResId = R.drawable.magnifying_glass, contentDescription = stringResource(CommonResR.string.search), onClick = { onSearchClicked.invoke() }, ) }, ) }, snackbarHost = { SnackbarHost( hostState = snackbarHostState, modifier = Modifier.xBottomPlayerPadding(isPlayerVisible()), ) }, ) { innerPadding -> Box( modifier = Modifier .padding(innerPadding) .background(ExtendedTheme.colors.backgroundPrimary) .fillMaxSize(), ) { when (viewState.isLoading) { true -> Box(modifier = Modifier.fillMaxSize()) { SunoSpinner( modifier = Modifier.align(Alignment.Center), ) } false -> { if (viewState.mySongsList.isEmpty() && activeSongFilter == allFilter) { Column { // Empty state LibraryTopRow( onLikedPlaylistClicked = onLikedPlaylistClicked, onPlaylistsClicked = onPlaylistsClicked, modifier = Modifier.padding(top = 12.dp).padding(horizontal = 16.dp), ) when (viewState.viewingContent) { ViewingContent.MySongs -> NoLibrarySongsEmptyState( modifier = Modifier.fillMaxSize(), isPlayerVisible = isPlayerVisible(), ) ViewingContent.LikedSongs -> NoLikedSongsEmptyState( modifier = Modifier.fillMaxSize(), isPlayerVisible = isPlayerVisible(), headerColor = ExtendedTheme.colors.textPrimary, bodyColor = ExtendedTheme.colors.textSecondary, ) } } } else { SunoPullToRefreshBox( modifier = Modifier.fillMaxSize(), isRefreshing = viewState.isRefreshing, onRefresh = { onEvent(LibraryScreenEvent.OnRefreshSongList) }, ) { LazyColumn( state = lazyListState, contentPadding = WindowInsets.omniPlayer.asPaddingValues(), modifier = Modifier .fillMaxSize() .background(color = ExtendedTheme.colors.backgroundPrimary) .consumeWindowInsets(WindowInsets.omniPlayer), ) { item { LibraryTopRow( onLikedPlaylistClicked = onLikedPlaylistClicked, onPlaylistsClicked = onPlaylistsClicked, modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), ) } item { Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 18.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row( verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.padding(end = 8.dp), text = stringResource(id = CommonResR.string.my_songs), color = ExtendedTheme.colors.textPrimary, style = ExtendedTheme.typography.introCopy, ) viewState.totalSongResultsNum?.let { totalSongResultsNum -> Row( modifier = Modifier .clip(RoundedCornerShape(16.dp)) .background(ExtendedTheme.colors.backgroundSecondary), verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.padding( start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp, ), imageVector = ImageVector.vectorResource(id = R.drawable.music), tint = ExtendedTheme.colors.textPrimary, contentDescription = "Songs in list", ) Text( modifier = Modifier.padding(end = 8.dp), text = totalSongResultsNum.toString(), color = ExtendedTheme.colors.textPrimary, style = ExtendedTheme.typography.cardMeta.copy( fontWeight = FontWeight.Bold, ), ) } } } Button( modifier = Modifier.clip(RoundedCornerShape(4.dp)), contentPadding = PaddingValues(horizontal = 8.dp), onClick = { showMySongsFilterBottomSheet = true }, colors = ButtonDefaults.buttonColors( containerColor = Color.Transparent, ), ) { Text( fontSize = 15.sp, text = if (activeSongFilter == allFilter) { stringResource( CommonResR.string.filter, ) } else { activeSongFilter }, color = ExtendedTheme.colors.textPrimary, ) Icon( painter = painterResource( id = R.drawable.filter, ), contentDescription = "Filter", tint = ExtendedTheme.colors.textPrimary, ) } } } if (viewState.viewingContent == ViewingContent.MySongs) { items(viewState.generatingSongIds.size) { Row(modifier = Modifier.padding(horizontal = 16.dp)) { GeneratingSongListItem() } } } if (viewState.mySongsList.isNotEmpty()) { items(viewState.mySongsList) { mySong: SongListData -> if (mySong.status.isReady) { SongListItem( clipImageUrl = mySong.displayImageUrl, songTitle = mySong.title?.takeIf { it.isNotEmpty() } ?: stringResource( id = CommonResR.string.untitled, ), songTags = mySong.tags ?: stringResource(CommonResR.string.no_style), playCount = mySong.playCount?.xAsFormattedCount(), modelBadge = mySong.modelBadge?.xAsUiModelBadge(), remixTag = mySong.remixTask, onClick = { onEvent(LibraryScreenEvent.OnSongClicked(mySong)) }, onOverflowClick = { onEvent(LibraryScreenEvent.OnSongOverflowOpened(mySong.id)) showSongActionContainerBottomSheet = true }, onUpgradeClicked = onUpgradeClicked, isFreeUser = viewState.isFreeUser == true, isPreviewClip = mySong.isPreview, ) } } } } } } } } } if (showSongActionContainerBottomSheet) { val currentlySelectedSong = viewState.mySongsList.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 -> { navigateToRemix.invoke(event.songId, event.task) } is SongActionBottomSheetConsumerEvent.NavigateToShareVideo -> { navigateToAssetEdit( event.songId, ) } is SongActionBottomSheetConsumerEvent.OnRemixClicked -> Unit is SongActionBottomSheetConsumerEvent.OnReusePromptClicked -> { onReusePromptClicked(event.formData) } is SongActionBottomSheetConsumerEvent.OnShowSnackbar -> { scope.launch { snackbarHostState.showSnackbar(event.message) } } is SongActionBottomSheetConsumerEvent.OnSongDeleted -> { onEvent(LibraryScreenEvent.OnSongDeleted(event.song)) } is SongActionBottomSheetConsumerEvent.OnSongRenamed -> { onEvent.invoke( LibraryScreenEvent.OnSongRenamed( event.song, ), ) } is SongActionBottomSheetConsumerEvent.OnSongReported -> { scope.launch { snackbarHostState.showSnackbar( message = event.message, ) } } is SongActionBottomSheetConsumerEvent.OnSongUndoDeleted -> Unit is SongActionBottomSheetConsumerEvent.OnSongUndoReported -> Unit SongActionBottomSheetConsumerEvent.OnUpgradeClicked -> { onUpgradeClicked.invoke() } is SongActionBottomSheetConsumerEvent.OnRemoveFromPlaylistTapped -> Unit } }, ) } } } if (showMySongsFilterBottomSheet) { SingleSelectionBottomSheet( sheetState = mySongsFilterSheetState, headerName = stringResource(CommonResR.string.filter), options = listOf( allFilter, privateFilter, publicFilter, likedFilter, ), activeOption = activeSongFilter, onSelect = { filterName -> activeSongFilter = filterName when (filterName) { allFilter -> onEvent(LibraryScreenEvent.OnAllFilterSelected) privateFilter -> onEvent(LibraryScreenEvent.OnPrivateFilterSelected) publicFilter -> onEvent(LibraryScreenEvent.OnPublicFilterSelected) likedFilter -> onEvent(LibraryScreenEvent.OnLikedFilterSelected) } }, onDismiss = { scope.launch { mySongsFilterSheetState.hide() } }, ) } } @Composable private fun LibraryTopRow( onLikedPlaylistClicked: () -> Unit, onPlaylistsClicked: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() .height(164.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { LibraryTopClickableItem( modifier = Modifier.weight(1f), text = stringResource(CommonResR.string.liked), imageResId = R.drawable.for_you_icon_bg, onClicked = { onLikedPlaylistClicked.invoke() }, ) Spacer(Modifier.width(16.dp)) LibraryTopClickableItem( modifier = Modifier.weight(1f), text = stringResource(CommonResR.string.playlists), imageResId = R.drawable.playlists_icon_bg, onClicked = { onPlaylistsClicked.invoke() }, ) } } @Preview @Composable private fun LibraryScreen_Preview() { SunoTheme { LibraryScreen( viewState = LibraryScreenState( isLoading = false, mySongsList = listOf( FakeSongListData.cat, FakeSongListData.cat.copy( id = Id("3"), title = "Electronic Beats", tags = "electronic, dance", ), ), isShowShareVideoGateEnabled = false, ), onEvent = {}, effects = emptyFlow(), onLikedPlaylistClicked = {}, onPlaylistsClicked = {}, onReusePromptClicked = {}, onUpgradeClicked = {}, onSearchClicked = {}, navigateToAssetEdit = {}, navigateToRemix = { _, _ -> }, ) } }