package com.suno.android.ui.screens.hooks.feed import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState 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.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.keepScreenOn import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.comments.MediaEntity import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_ui.components.bottom_sheet.ShareLinkBottomSheet 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.loading.SunoSpinner import com.suno.android.common_ui.extensions.collectCurrentPositionAsState import com.suno.android.common_ui.extensions.conditional import com.suno.android.common_ui.extensions.windowInsetsPadding import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.ui.bottom_sheets.comments.CommentsBottomSheet import com.suno.android.ui.bottom_sheets.create.RemixTypeSelectionSheetContent import com.suno.android.ui.bottom_sheets.playlists.AddToPlaylistBottomSheet import com.suno.android.ui.screens.create.CreatePromptForm import com.suno.android.ui.screens.create.audio.CreateAudioState import com.suno.android.ui.screens.hooks.HookPlayerSurface import com.suno.android.ui.screens.hooks.clip.HookClipPlayerContent import com.suno.android.ui.screens.hooks.overlay.HookOverlay import com.suno.android.ui.screens.hooks.overlay.more.HookMoreMenuContent import com.suno.android.utils.mvi.MviEffectHandler import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.time.Duration private const val BEYOND_VIEWPORT_PAGE_COUNT = 1 private const val HOOK_GRADIENT_TOP_STOP = 0.161f private const val HOOK_GRADIENT_BOTTOM_START = 0.5f private val hookGradient = Brush.verticalGradient( 0.0f to Color.Black.copy(alpha = 0.2f), HOOK_GRADIENT_TOP_STOP to Color.Transparent, HOOK_GRADIENT_BOTTOM_START to Color.Transparent, 1.0f to Color.Black.copy(alpha = 0.5f), ) @Composable fun HooksFeedContent( vm: HooksFeedController, onNavigationRequest: (HooksFeedNavigationRequest) -> Unit, modifier: Modifier = Modifier, pagerState: PagerState = rememberPagerState( initialPage = vm.state.value.currentIndex, pageCount = { vm.state.value.hooks.size }, ), showCommentsBottomSheetOnNavigate: Boolean = false, showHookOverlay: Boolean = true, ) { LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.sendEvent(HooksFeedEvent.OnResume) } LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { vm.sendEvent(HooksFeedEvent.OnPause) } DisposableEffect(Unit) { onDispose { vm.sendEvent(HooksFeedEvent.OnDispose) } } val viewState by vm.state.collectAsStateWithLifecycle() val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } MviEffectHandler(effects = vm.effects, onBound = null) { effect -> when (effect) { is HooksFeedEffect.ShareLink -> { coroutineScope.launch { handleShareClicked( platform = effect.sharePlatform, shareLink = effect.link.url, context = context, clipboardManager = clipboardManager, ) } } is HooksFeedEffect.NavigateToProfile -> { onNavigationRequest(HooksFeedNavigationRequest.CreatorClick(effect.handle)) } is HooksFeedEffect.ShowSnackbar -> { val message = context.getString(effect.type.messageResId) snackbarHostState.showSnackbar(message) } is HooksFeedEffect.NavigateToTargetHook -> { pagerState.animateScrollToPage(effect.targetIndex) } HooksFeedEffect.ResetScroll -> { with(pagerState) { scroll(MutatePriority.PreventUserInput) { updateCurrentPage(0) } } } } } HooksFeedContent( vm = vm, pagerState = pagerState, snackbarHostState = snackbarHostState, modifier = modifier, showHooksOverlay = showHookOverlay, ) if (viewState.showShareSheet) { viewState.currentHook?.let { hook -> ShareLinkBottomSheet( onDismiss = { vm.sendEvent(HooksFeedEvent.DismissShareSheet) }, onShareClicked = { platform -> vm.sendEvent(HooksFeedEvent.StartShare(hook, platform)) }, ) } } if (viewState.showCommentsSheet) { viewState.currentHook?.let { hook -> CommentsBottomSheet( mediaEntity = MediaEntity.HookEntity(hookId = hook.hookId), authorId = hook.creator.userId, authorHandle = hook.creator.handle, onDismiss = { vm.sendEvent(HooksFeedEvent.CommentsSheetDismissed) }, sheetState = rememberModalBottomSheetState(), onProfileClicked = { handle -> vm.sendEvent(HooksFeedEvent.NavigateToCreatorProfile(handle, hook)) }, ) { vm.sendEvent(HooksFeedEvent.OnCommentBeginWrite(hook)) } } } HooksFeedScreenBottomSheets( state = viewState.bottomSheetState, onDismissRequest = { action -> vm.sendEvent(HooksFeedEvent.OnCloseBottomSheetRequested(action)) }, onArtistHandleClick = { onNavigationRequest(HooksFeedNavigationRequest.CreatorClick(it)) }, navigateToAssetEdit = { onNavigationRequest(HooksFeedNavigationRequest.AssetEdit(it)) }, onReusePromptClick = { onNavigationRequest(HooksFeedNavigationRequest.ReusePrompt(it)) }, onGoToFullSongClick = { hook -> vm.sendEvent(HooksFeedEvent.GoToFullSong(hook)) }, onDownloadHookClick = { hook -> vm.sendEvent(HooksFeedEvent.DownloadHook(hook)) }, onHideCreatorClick = { hook -> vm.sendEvent(HooksFeedEvent.HideCreator(hook)) }, onNotInterestedClick = { hook -> vm.sendEvent(HooksFeedEvent.NotInterested(hook)) }, onReportInappropriateClick = { hook -> vm.sendEvent(HooksFeedEvent.ReportInappropriate(hook)) }, navigateToRemix = { clipId, tasks -> onNavigationRequest(HooksFeedNavigationRequest.Remix(clipId, tasks)) }, onSongPlaylistStatusChanged = { songId, isInAnyPlaylist -> val event = HooksFeedEvent.SongPlaylistStatusChanged( songId = songId, isInAnyPlaylist = isInAnyPlaylist, ) vm.sendEvent(event) }, ) // this needs to be at the bottom of the function // pop open the comments bottom sheet AFTER loading the new songId in CommentsBottomSheet func invocation LaunchedEffect(showCommentsBottomSheetOnNavigate) { if (showCommentsBottomSheetOnNavigate) { vm.openCommentsBottomSheet() } } } @Composable private fun HooksFeedContent( vm: HooksFeedController, pagerState: PagerState, snackbarHostState: SnackbarHostState, modifier: Modifier = Modifier, showHooksOverlay: Boolean = true, ) { val viewState by vm.state.collectAsStateWithLifecycle() Scaffold( containerColor = ExtendedTheme.colors.backgroundPrimary, contentWindowInsets = WindowInsets.navigationBars, snackbarHost = { SnackbarHost( hostState = snackbarHostState, ) }, modifier = modifier, ) { contentPadding -> Box( modifier = Modifier .fillMaxSize() .windowInsetsPadding(contentPadding), contentAlignment = Alignment.Center, ) { if (viewState.isLoading || viewState.hooks.isEmpty()) { SunoSpinner() } else { HooksFeed( viewState = viewState, vm = vm, pagerState = pagerState, showHooksOverlay = showHooksOverlay, ) } } } } /** * The main vertical pager component for browsing hooks with automatic page tracking. */ @Composable private fun HooksFeed( pagerState: PagerState, viewState: HooksFeedUiState, vm: HooksFeedController, showHooksOverlay: Boolean = true, ) { LaunchedEffect(pagerState.currentPage) { vm.sendEvent(HooksFeedEvent.CurrentPageChanged(pagerState.currentPage)) } VerticalPager( state = pagerState, modifier = Modifier.fillMaxSize(), beyondViewportPageCount = BEYOND_VIEWPORT_PAGE_COUNT, ) { pageIndex -> val hook = viewState.hooks.getOrNull(pageIndex) ?: return@VerticalPager HookItem( pageIndex = pageIndex, currentPageIndex = pagerState.currentPage, hook = hook, viewState = viewState, vm = vm, showHooksOverlay = showHooksOverlay, ) } } /** * Individual hook item with video player and overlay UI. */ @OptIn(UnstableApi::class) @Composable private fun HookItem( pageIndex: Int, currentPageIndex: Int, hook: LocalHookData, viewState: HooksFeedUiState, vm: HooksFeedController, showHooksOverlay: Boolean = true, ) { val player by vm.getVideoPlayerState(hook, pageIndex).collectAsStateWithLifecycle() LaunchedEffect(hook.hookId, pageIndex, currentPageIndex) { // If user scrolls fast enough, it's possible for the hook item to not get disposed in time and thus still // be part of composition but its player was already re-assigned. If we're within the viewport page count and // there is no player, prepare media to get player assigned to avoid black screen. if (player == null && abs(pageIndex - currentPageIndex) <= BEYOND_VIEWPORT_PAGE_COUNT) { vm.prepareMedia(hook, pageIndex) } } val isCurrentPage = pageIndex == currentPageIndex LaunchedEffect(isCurrentPage) { if (isCurrentPage) { vm.sendEvent(HooksFeedEvent.HookFocused(hook, pageIndex)) } } DisposableEffect(hook.hookId) { onDispose { vm.handleHookDispose(hook.hookId) } } val playPauseState = player?.let { rememberPlayPauseButtonState(it) } val playState = when { viewState.isFeedMuted -> HookPlayState.Muted viewState.userPausedHooks.contains(hook.hookId) -> HookPlayState.UserPaused playPauseState?.showPlay == false -> HookPlayState.Playing else -> HookPlayState.Other } val playerPosition = player?.collectCurrentPositionAsState() Box( modifier = Modifier .padding(bottom = 8.dp) .clip(RoundedCornerShape(36.dp)) .conditional( condition = (playState == HookPlayState.Playing), ifTrue = { keepScreenOn() }, ifFalse = { this }, ), contentAlignment = Alignment.BottomStart, ) { player?.let { HookPlayerSurface( player = it, thumbnailUrl = hook.thumbnailImageUrl, ) } Box( modifier = Modifier .fillMaxSize() .background(brush = hookGradient), ) AnimatedVisibility( visible = showHooksOverlay, enter = fadeIn(), exit = fadeOut(), ) { HookOverlay( hook = hook, playState = playState, isCaptionExpanded = viewState.captionExpandedHookId == hook.hookId, playerPosition = playerPosition?.value ?: Duration.ZERO, isClipSaved = viewState.songPlaylistStatusMap[hook.clip.clipId] ?: false, isCreatorHidden = vm.isCreatorHidden(hook), onVideoClick = { vm.sendEvent(HooksFeedEvent.TogglePlayback(hook.hookId)) }, onLikeButtonClick = { vm.sendEvent(HooksFeedEvent.ToggleLike(hook)) }, onDoubleTapLike = { vm.sendEvent(HooksFeedEvent.DoubleTapLike(hook)) }, onCommentsButtonClick = { vm.sendEvent(HooksFeedEvent.ShowCommentsSheet(hook)) }, onFollowCreatorClick = { vm.sendEvent(HooksFeedEvent.ToggleFollowCreator(hook)) }, onShareButtonClick = { vm.sendEvent(HooksFeedEvent.ShowShareSheet(hook)) }, onMoreMenuClick = { vm.sendEvent(HooksFeedEvent.ShowMoreMenu(hook)) }, onAddClipToPlaylistClick = { vm.sendEvent(HooksFeedEvent.ShowAddToPlaylistSheet(hook)) }, onCreatorClick = { handle -> vm.sendEvent(HooksFeedEvent.NavigateToCreatorProfile(handle, hook)) }, onSongCapsuleClick = { vm.sendEvent(HooksFeedEvent.ShowClipPlayer(hook)) }, onRemixButtonClick = if (viewState.isRemixClipGateEnabled && viewState.currentHook?.clip?.canRemix == true ) { { vm.sendEvent(HooksFeedEvent.ShowRemixSelectionSheet(hook)) } } else { null }, onCaptionClick = { vm.sendEvent(HooksFeedEvent.ToggleCaptionExpansion(hook)) }, currentUserHandle = viewState.currentUserHandle, ) } } } @Composable private fun HooksFeedScreenBottomSheets( state: HooksFeedUiState.BottomSheetState, onDismissRequest: (BottomSheetDismissalAction) -> Unit, onArtistHandleClick: (handle: UserHandle) -> Unit, navigateToAssetEdit: (songId: Id) -> Unit, onReusePromptClick: (CreatePromptForm) -> Unit, onGoToFullSongClick: (LocalHookData) -> Unit, onDownloadHookClick: (LocalHookData) -> Unit, onHideCreatorClick: (LocalHookData) -> Unit, onNotInterestedClick: (LocalHookData) -> Unit, onReportInappropriateClick: (LocalHookData) -> Unit, navigateToRemix: (songId: Id, task: CreateAudioState.AudioCreateTask) -> Unit, onSongPlaylistStatusChanged: (songId: Id, isInAnyPlaylist: Boolean) -> Unit, modifier: Modifier = Modifier, ) { StateBottomSheet( state = state, isShowing = { it != HooksFeedUiState.BottomSheetState.Hidden }, showHandle = { it is HooksFeedUiState.BottomSheetState.HasHandle }, onDismissRequest = { onDismissRequest(BottomSheetDismissalAction.SwipeDown) }, onBack = { onDismissRequest(BottomSheetDismissalAction.BackNav) }, modifier = modifier, containerColor = ExtendedTheme.colors.backgroundPrimary, ) { currentState -> when (currentState) { HooksFeedUiState.BottomSheetState.Hidden -> Spacer(modifier = Modifier.fillMaxSize()) is HooksFeedUiState.BottomSheetState.ClipPlayerVisible -> HookClipPlayerContent( controller = currentState.controller, onDismiss = onDismissRequest, onArtistHandleClick = onArtistHandleClick, navigateToAssetEdit = navigateToAssetEdit, onReusePromptClick = onReusePromptClick, navigateToRemix = navigateToRemix, ) is HooksFeedUiState.BottomSheetState.AddToPlaylist -> AddToPlaylistBottomSheet( songId = currentState.hook.clip.clipId, hook = currentState.hook, onDismiss = { onDismissRequest(BottomSheetDismissalAction.Unspecified) }, onSongPlaylistStatusChanged = onSongPlaylistStatusChanged, ) is HooksFeedUiState.BottomSheetState.MoreMenuVisible -> HookMoreMenuContent( hook = currentState.hook, isOwnHook = currentState.isOwnHook, onGoToFullSongClick = { onGoToFullSongClick(currentState.hook) }, onHideCreatorClick = { onHideCreatorClick(currentState.hook) }, onNotInterestedClick = { onNotInterestedClick(currentState.hook) }, onReportInappropriateClick = { onReportInappropriateClick(currentState.hook) }, isDownloadEnabled = currentState.isDownloadEnabled, onDownloadClick = { onDownloadHookClick(currentState.hook) }, ) is HooksFeedUiState.BottomSheetState.RemixTypeSelectionVisible -> RemixTypeSelectionSheetContent( clip = currentState.clip, onDismiss = { onDismissRequest(BottomSheetDismissalAction.Unspecified) }, onOptionClick = { remixType -> navigateToRemix(currentState.clip.clipId, remixType.task) }, ) } } }