package com.suno.android.ui.screens.playlist import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState 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.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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.suno.android.R 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_ui.components.ArtistMiniDetail import com.suno.android.common_ui.components.bottom_sheet.RenameSongOrPlaylistBottomSheet import com.suno.android.common_ui.components.infos.empty_states.NoLikedSongsEmptyState import com.suno.android.common_ui.components.list_items.BottomSheetSongActionItem 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.effects.PaletteColorExtractionEffect import com.suno.android.common_ui.extensions.xAsFormattedCount import com.suno.android.common_ui.extensions.xIsHighContrastAgainst import com.suno.android.common_ui.theme.DarkTextPrimary import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.SunoTheme import com.suno.android.common_ui.theme.TextPrimary 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 kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR @Composable fun PlaylistScreen( vm: PlaylistScreenVM? = hiltViewModel(), onBackClicked: () -> Unit, onPlaylistDeletion: () -> Unit, onReusePromptClicked: (CreatePromptForm) -> Unit, onUserClicked: (handle: UserHandle) -> Unit, onUpgradeClicked: () -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask?) -> Unit, ) { val viewState = vm?.state?.collectAsStateWithLifecycle()?.value val defaultBg = ExtendedTheme.colors.backgroundPrimary var backgroundGradientColor by remember { mutableStateOf(defaultBg) } var onBackgroundGradientColor by remember { mutableStateOf(Color.Black) } val snackbarHostState = remember { SnackbarHostState() } var showPlaylistActionBottomSheet by remember { mutableStateOf(false) } val songActionsContainerHostState: SheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, // Allow partial expansion, ) var showSongActionContainerBottomSheet: Boolean by remember { mutableStateOf(false) } var showRenamePlaylistBottomSheet by remember { mutableStateOf(false) } val playlistActionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val playlistRenameSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scope = rememberCoroutineScope() val context = LocalContext.current LaunchedEffect(Unit) { vm?.effects?.collect { effect -> when (effect) { is PlaylistScreenEffect.ShowSnackbar -> { if (effect.message.isNotEmpty()) { snackbarHostState.showSnackbar(effect.message) } } is PlaylistScreenEffect.DeletePlaylist -> { onPlaylistDeletion.invoke() } } } } // 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 } // We need this launched effect for the initial setting of the modal sheet state so it'll animate properly for FIRST launch LaunchedEffect(showPlaylistActionBottomSheet) { if (showPlaylistActionBottomSheet) { // Sheet is now in composition, animate it playlistActionsSheetState.show() } } // Bidirectional sync for sheet state so it'll animate LaunchedEffect(playlistActionsSheetState.isVisible) { showPlaylistActionBottomSheet = playlistActionsSheetState.isVisible } // We need this launched effect for the initial setting of the modal sheet state so it'll animate properly for FIRST launch LaunchedEffect(showRenamePlaylistBottomSheet) { if (showRenamePlaylistBottomSheet) { // Sheet is now in composition, animate it playlistRenameSheetState.show() } } // Bidirectional sync for sheet state so it'll animate LaunchedEffect(playlistRenameSheetState.isVisible) { showRenamePlaylistBottomSheet = playlistRenameSheetState.isVisible } val isLikedPlaylist = viewState?.playlist?.id == LIKED_PLAYLIST_ID Box( modifier = Modifier .fillMaxSize() .background(backgroundGradientColor), contentAlignment = Alignment.Center, ) { if (viewState?.loading == true) { SunoSpinner() } else { var pageContentInitialized by remember { mutableStateOf(false) } LaunchedEffect(Unit) { pageContentInitialized = true } AnimatedVisibility( visible = pageContentInitialized, enter = fadeIn(initialAlpha = 0f, animationSpec = tween(durationMillis = 300)), ) { AsyncImage( modifier = Modifier .fillMaxWidth() .height(400.dp) .align(Alignment.TopCenter), model = viewState?.imageUrl, contentDescription = null, contentScale = ContentScale.Crop, fallback = painterResource(R.drawable.ic_launcher_background), ) PaletteColorExtractionEffect( imageUrl = viewState?.imageUrl, onPaletteExtracted = { palette -> val dominantSwatch = palette?.dominantSwatch val extractedDominantColor = dominantSwatch?.rgb?.let { Color(it) } ?: Color.Unspecified val extractedDominantTextColor = dominantSwatch?.titleTextColor?.let { Color(it) } ?: Color.Unspecified val lightVibrantSwatch = palette?.lightVibrantSwatch val extractedLightVibrantColor = lightVibrantSwatch?.rgb?.let { Color(it) } ?: Color.Unspecified val extractedLightVibrantTextColor = lightVibrantSwatch?.bodyTextColor?.let { Color(it) } ?: Color.Unspecified val vibrantSwatch = palette?.vibrantSwatch val extractedVibrantColor = vibrantSwatch?.rgb?.let { Color(it) } ?: Color.Unspecified val extractedVibrantTextColor = vibrantSwatch?.bodyTextColor?.let { Color(it) } ?: Color.Unspecified val darkVibrantSwatch = palette?.darkVibrantSwatch val extractedDarkVibrantColor = darkVibrantSwatch?.rgb?.let { Color(it) } ?: Color.Unspecified val extractedDarkVibrantTextColor = darkVibrantSwatch?.titleTextColor?.let { Color(it) } ?: Color.Unspecified val mutedSwatch = palette?.mutedSwatch val extractedMutedColor = mutedSwatch?.rgb?.let { Color(it) } ?: Color.Unspecified val extractedMutedTextColor = mutedSwatch?.titleTextColor?.let { Color(it) } ?: Color.Unspecified backgroundGradientColor = extractedDominantColor onBackgroundGradientColor = extractedDominantTextColor.copy(alpha = 1f) }, ) SunoPullToRefreshBox( modifier = Modifier.fillMaxSize(), isRefreshing = viewState?.isRefreshing == true, onRefresh = { vm?.sendEvent(PlaylistScreenEvent.OnRefreshPlaylist) }, ) { LazyColumn( modifier = Modifier .fillMaxSize() .navigationBarsPadding(), contentPadding = PaddingValues(top = 200.dp), ) { item { val textColor = if (backgroundGradientColor.xIsHighContrastAgainst(Color.Black)) { TextPrimary } else { DarkTextPrimary } Box( modifier = Modifier .fillMaxWidth() .height(200.dp) .align(Alignment.BottomCenter) .background( Brush.verticalGradient( colors = listOf( Color.Transparent, backgroundGradientColor.copy(alpha = 0.99f), backgroundGradientColor, ), ), ), ) { Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), verticalArrangement = Arrangement.Bottom, ) { Text( modifier = Modifier.padding(bottom = 8.dp), text = viewState?.playlistTitle ?: stringResource( CommonResR.string.untitled, ), style = ExtendedTheme.typography.largeHeadline, color = textColor, maxLines = 2, ) if (!isLikedPlaylist) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { ArtistMiniDetail( creatorAvatarUrl = viewState?.userAvatarUrl, creatorName = viewState?.userDisplayName ?: "", backgroundColor = Color.Transparent, textColor = textColor, onClick = { viewState?.userHandle?.let { onUserClicked.invoke(it) } }, ) val isPublic = viewState?.isPublic == true Row( verticalAlignment = Alignment.CenterVertically, ) { Icon( modifier = Modifier.padding(end = 4.dp), imageVector = ImageVector.vectorResource( id = if (isPublic) { CommonUIR.drawable.globe } else { CommonUIR.drawable.lock_keyhole }, ), contentDescription = "Public", tint = textColor, ) Text( text = if (isPublic) { stringResource(CommonResR.string.public_) } else { stringResource(CommonResR.string.private_) }, style = ExtendedTheme.typography.smallBody, color = textColor, ) } } } Row( modifier = Modifier .fillMaxWidth() .height(96.dp), verticalAlignment = Alignment.CenterVertically, ) { val showBorder = !backgroundGradientColor.xIsHighContrastAgainst(Color.White) Row( modifier = Modifier .weight(1f) .padding(top = 8.dp, bottom = 8.dp, end = 8.dp) .height(56.dp) .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .background(Color.White) .clickable { val playlistShareText = if (viewState?.playlist?.isOwned == true) { context.getString( CommonResR.string.share_own_playlist, viewState.playlist.id, ) } else { context.getString( CommonResR.string.share_other_playlist, viewState?.playlist?.id, ) } val sendIntent: Intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, playlistShareText, ) } val shareIntent = Intent.createChooser(sendIntent, null) context.startActivity(shareIntent) } .run { if (showBorder) { border( width = 1.dp, color = Color.White, shape = RoundedCornerShape(8.dp), ) } else { this } }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { Icon( modifier = Modifier.padding(horizontal = 4.dp), imageVector = ImageVector.vectorResource( id = com.suno.android.common_ui.R.drawable.share_android, ), tint = Color.Black, contentDescription = null, ) Text( modifier = Modifier.padding(horizontal = 4.dp), text = stringResource(id = CommonResR.string.share), color = Color.Black, style = ExtendedTheme.typography.playlistCards, ) } val playable = viewState?.songListData?.isNotEmpty() ?: false val showPlayBorder = !backgroundGradientColor.xIsHighContrastAgainst(Color.White) IconButton( modifier = Modifier .size(56.dp) .background( color = if (showPlayBorder) Color.Transparent else Color.Black, shape = RoundedCornerShape(8.dp), ) .run { if (showPlayBorder) { border( width = 1.dp, color = Color.White, shape = RoundedCornerShape(8.dp), ) } else { this } }, onClick = { viewState?.songListData?.first()?.let { vm.sendEvent( PlaylistScreenEvent.OnIncrementPlaylistPlaycount( clipId = it.id, ), ) vm.sendEvent( PlaylistScreenEvent.OnSongSelected( clipId = it.id, ), ) } }, enabled = playable, ) { Icon( imageVector = ImageVector.vectorResource( id = com.suno.android.common_ui.R.drawable.play, ), tint = Color.White, contentDescription = null, ) } } } } if (viewState?.songListData?.isEmpty() == true && isLikedPlaylist) { NoLikedSongsEmptyState( modifier = Modifier .fillMaxSize() .padding(top = 32.dp), isPlayerVisible = vm.isPlayerVisible(), headerColor = onBackgroundGradientColor, bodyColor = lerp( backgroundGradientColor, onBackgroundGradientColor, 0.3f, ), ) } } items( viewState?.songListData ?: emptyList(), ) { mySong: SongListData -> SongListItem( backgroundColor = backgroundGradientColor, textColor = onBackgroundGradientColor, 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(), upvoteCount = mySong.upvoteCount?.xAsFormattedCount(), modelBadge = mySong.modelBadge?.xAsUiModelBadge(), onClick = { vm?.sendEvent( PlaylistScreenEvent.OnIncrementPlaylistPlaycount( clipId = mySong.id, ), ) vm?.sendEvent(PlaylistScreenEvent.OnSongSelected(clipId = mySong.id)) }, onOverflowClick = { vm?.sendEvent(PlaylistScreenEvent.OnSongOverflowOpened(songId = mySong.id)) showSongActionContainerBottomSheet = true }, onUpgradeClicked = onUpgradeClicked, isFreeUser = viewState?.isFreeUser == true, isPreviewClip = mySong.isPreview, ) HorizontalDivider( thickness = 1.dp, color = lerp(backgroundGradientColor, onBackgroundGradientColor, 0.3f), ) } item { Spacer( modifier = Modifier .fillMaxWidth() .height(200.dp), ) } } } } } Row( modifier = Modifier .statusBarsPadding() .fillMaxWidth() .height(64.dp) .align(Alignment.TopCenter), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { CircleIconButton( modifier = Modifier .alpha(0.75f) .padding(horizontal = 16.dp), vectorDrawableResId = com.suno.android.common_ui.R.drawable.arrow_left, contentDescription = "Back", onClick = { onBackClicked.invoke() }, ) if (viewState?.playlist?.isOwned == true && !isLikedPlaylist) { CircleIconButton( modifier = Modifier .alpha(0.75f) .padding(horizontal = 16.dp), vectorDrawableResId = com.suno.android.common_ui.R.drawable.ellipsis_vertical, contentDescription = "Playlist Actions", onClick = { showPlaylistActionBottomSheet = true }, ) } } if (showPlaylistActionBottomSheet) { ModalBottomSheet( onDismissRequest = { scope.launch { playlistActionsSheetState.hide() } }, sheetState = playlistActionsSheetState, dragHandle = null, ) { val statusBarHeightDp = with(LocalDensity.current) { WindowInsets.statusBars.getTop(LocalDensity.current).toDp() } Column( modifier = Modifier .fillMaxWidth() .defaultMinSize(minHeight = 128.dp) .heightIn( max = (LocalConfiguration.current.screenHeightDp - statusBarHeightDp.value).dp, ), ) { Box( modifier = Modifier .fillMaxWidth() .height(96.dp), ) { Text( modifier = Modifier.align(Alignment.Center), text = stringResource(id = CommonResR.string.playlist_actions), style = ExtendedTheme.typography.introCopy, ) CircleIconButton( modifier = Modifier .align(Alignment.CenterEnd) .padding(24.dp), vectorDrawableResId = com.suno.android.common_ui.R.drawable.close, contentDescription = "Close", onClick = { scope.launch { playlistActionsSheetState.hide() } }, ) } // Playlist Actions LazyColumn( modifier = Modifier.fillMaxWidth(), ) { item { BottomSheetSongActionItem( vectorDrawableRes = com.suno.android.common_ui.R.drawable.pen_field, displayText = stringResource(id = CommonResR.string.rename_playlist), onClick = { showRenamePlaylistBottomSheet = true scope.launch { playlistActionsSheetState.hide() } }, ) } if (!isLikedPlaylist) { item { val isPublic = viewState?.isPublic == true BottomSheetSongActionItem( vectorDrawableRes = if (!isPublic) { CommonUIR.drawable.globe } else { CommonUIR.drawable.lock_keyhole }, displayText = if (!isPublic) { stringResource(id = CommonResR.string.make_public) } else { stringResource(CommonResR.string.make_private) }, onClick = { vm?.sendEvent(PlaylistScreenEvent.OnTogglePlaylistVisibility) }, ) } } item { BottomSheetSongActionItem( vectorDrawableRes = CommonUIR.drawable.trash, displayText = stringResource(id = CommonResR.string.delete_playlist), colorTint = Color.Red, onClick = { vm?.sendEvent(PlaylistScreenEvent.OnShowDeleteDialog) scope.launch { playlistActionsSheetState.hide() } }, ) } } } } } if (showSongActionContainerBottomSheet) { val currentlySelectedSong = viewState?.songListData?.find { it.id == viewState.songIdToOperate } currentlySelectedSong?.let { SongActionsBottomSheet( song = currentlySelectedSong, canRemoveFromPlaylist = viewState.playlist?.isOwned == true, isShowShareVideoGateEnabled = viewState.isShowShareVideoGateEnabled, songActionBottomSheetConsumerEvents = { event -> when (event) { SongActionBottomSheetConsumerEvent.DismissSongActionsContainerHost -> { scope.launch { songActionsContainerHostState.hide() } } is SongActionBottomSheetConsumerEvent.NavigateToRemix -> { scope.launch { songActionsContainerHostState.hide() showSongActionContainerBottomSheet = false } navigateToRemix.invoke( event.songId, event.task, ) } is SongActionBottomSheetConsumerEvent.NavigateToShareVideo -> { navigateToAssetEdit.invoke(event.songId) } is SongActionBottomSheetConsumerEvent.OnRemixClicked -> Unit is SongActionBottomSheetConsumerEvent.OnReusePromptClicked -> { onReusePromptClicked.invoke(event.formData) } is SongActionBottomSheetConsumerEvent.OnShowSnackbar -> { vm.triggerSnackbar(message = event.message) } is SongActionBottomSheetConsumerEvent.OnSongDeleted -> { vm.sendEvent( PlaylistScreenEvent.OnSongDeleted( deletedSong = event.song, ), ) } is SongActionBottomSheetConsumerEvent.OnSongRenamed -> { vm.sendEvent(PlaylistScreenEvent.OnSongRenamed(renamedSong = 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 -> { vm.sendEvent( PlaylistScreenEvent.OnRemoveSongFromPlaylist( songListData = event.song, ), ) } } }, ) } SnackbarHost( modifier = Modifier .align(Alignment.BottomCenter) .padding( bottom = if (vm?.isPlayerVisible() == true) 190.dp else 120.dp, start = 16.dp, end = 16.dp, ), hostState = snackbarHostState, ) { Snackbar( containerColor = ExtendedTheme.colors.backgroundInvert, ) { Text( text = snackbarHostState.currentSnackbarData?.visuals?.message.toString(), color = ExtendedTheme.colors.backgroundPrimary, ) } } } if (showRenamePlaylistBottomSheet) { RenameSongOrPlaylistBottomSheet( sheetState = playlistRenameSheetState, isRenameSongMode = false, value = viewState?.playlistTitle?.takeIf { it.isNotBlank() } ?: stringResource(CommonResR.string.untitled), onConfirm = { newValue -> vm?.sendEvent(PlaylistScreenEvent.OnChangePlaylistTitle(title = newValue)) scope.launch { playlistRenameSheetState.hide() } }, onDismiss = { scope.launch { playlistRenameSheetState.hide() } }, ) } } } @Preview @Composable private fun PlaylistScreen_Preview() { SunoTheme { PlaylistScreen( vm = null, onBackClicked = {}, onPlaylistDeletion = {}, onReusePromptClicked = {}, onUserClicked = {}, onUpgradeClicked = {}, navigateToAssetEdit = {}, navigateToRemix = { _, _ -> }, ) } }