package com.suno.android.ui.screens.hooks.home import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.Animatable import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.suno.android.common_core_utils.environment.ThemeMode import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_ui.animations.shimmerBrush import com.suno.android.common_ui.components.bottom_sheet.StateBottomSheet import com.suno.android.common_ui.components.omni.CircleIconButton import com.suno.android.common_ui.components.player.PlayerActionButton import com.suno.android.common_ui.components.preview.SunoPreview import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.SunoTheme import com.suno.android.ui.screens.hooks.create.song_select.HooksSongSelectContent import com.suno.android.ui.screens.hooks.feed.BottomSheetDismissalAction import com.suno.android.ui.screens.hooks.feed.HooksFeedContent import com.suno.android.ui.screens.hooks.feed.HooksFeedEvent import com.suno.android.ui.screens.hooks.feed.HooksFeedNavigationRequest import com.suno.android.ui.screens.hooks.home.HooksHomeEvent.OnHooksFeedEvent import com.suno.android.ui.screens.hooks.home.component.HooksOnboardingContent import com.suno.android.ui.screens.hooks.home.component.ShortcutCarousel import com.suno.android.ui.screens.hooks.overlay.HooksMutedOverlay import com.suno.android.ui.screens.hooks.shortcut_carousel.ShortcutCarouselState import com.suno.android.utils.mvi.MviEffectHandler import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlin.math.absoluteValue import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUiR private const val HOOKS_COLLAPSED_PADDING = 12 private const val GESTURE_COLLAPSE_PERCENTAGE_ENABLED = .2f private const val ALPHA_TRANSPARENT = 0f private const val ALPHA_OPAQUE = 1f private const val SUNO_LOGO_TOP_BAR_HEIGHT = 48 private const val SUNO_LOGO_CAROUSEL_SPACE = 12 /** * Vertical feed displaying short-form video hooks. */ @Composable fun HooksHomeScreen( vm: HooksHomeVM = hiltViewModel(), onNavigationRequest: (HooksHomeNavigationRequest) -> Unit, ) { val carouselState by vm.shortcutCarouselController.state.collectAsStateWithLifecycle() val viewState by vm.state.collectAsStateWithLifecycle() DisposableEffect(Unit) { vm.sendEvent(HooksHomeEvent.OnUiVisible) onDispose { vm.sendEvent(HooksHomeEvent.OnUiHidden) } } SunoTheme( themeMode = ThemeMode.DARK, ) { val onboardingState = viewState.onboardingState when { onboardingState != null -> { HooksOnboardingContent( player = onboardingState.player, onExploreClick = { vm.sendEvent(HooksHomeEvent.OnOnboardingDismiss) }, ) } carouselState is ShortcutCarouselState.Disabled -> { val pagerState = rememberPagerState( initialPage = viewState.hooksFeedState.currentIndex, pageCount = { viewState.hooksFeedState.hooks.size }, ) Box { HooksFeedContent( vm = vm.hooksFeedController, pagerState = pagerState, onNavigationRequest = { onNavigationRequest(it.toHooksHomeNavigationRequest()) }, modifier = Modifier.fillMaxSize(), ) SunoLogo( modifier = Modifier .pagerStateAlpha(pagerState) .align(Alignment.TopStart) .statusBarsPadding(), ) if (viewState.isShowHooksCreateEntryPointGateEnabled) { HookCreateButton( onClick = { vm.sendEvent(HooksHomeEvent.OnCreateClick) }, modifier = Modifier .align(Alignment.TopEnd) .statusBarsPadding() .padding(end = 8.dp), ) } } } else -> { HooksFeedWithTopCarousel( vm = vm, onNavigationRequest = onNavigationRequest, ) } } HooksHomeBottomSheet( state = viewState.bottomSheetState, onCreateSongSelected = { onNavigationRequest(HooksHomeNavigationRequest.CreateHookWithClip(it)) }, onDismissRequest = { action -> vm.sendEvent(HooksHomeEvent.OnCloseBottomSheetRequested(action)) }, modifier = Modifier, ) } } private enum class TopBarState { Expanded, Collapsed, MidState, } @Composable private fun HooksFeedWithTopCarousel( vm: HooksHomeVM, onNavigationRequest: (HooksHomeNavigationRequest) -> Unit, ) { // todo: because we use topBarBehavior, we don't have ideal control. Honestly this API is a bit weird // the only real drawback is that we can't cancel flings, which cause our animation to cancel val topBarBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val coroutineScope = rememberCoroutineScope() MviEffectHandler(vm.effects) { effect -> when (effect) { HooksHomeEffect.ExpandHooksCarousel -> { topBarBehavior.animateOpen() } } } val viewState by vm.state.collectAsStateWithLifecycle() val pagerState = rememberPagerState( initialPage = viewState.hooksFeedState.currentIndex, pageCount = { viewState.hooksFeedState.hooks.size }, ) val isExpandGestureEnabled by remember { derivedStateOf { topBarBehavior.state.collapsedFraction < GESTURE_COLLAPSE_PERCENTAGE_ENABLED } } val topBarState by remember { derivedStateOf { when (topBarBehavior.state.collapsedFraction) { 1f -> TopBarState.Collapsed 0f -> TopBarState.Expanded else -> TopBarState.MidState } } } LaunchedEffect(topBarState) { when (topBarState) { TopBarState.Expanded -> { vm.sendEvent(OnHooksFeedEvent(HooksFeedEvent.MuteFeedFromCarouselExpand)) } TopBarState.Collapsed -> { vm.sendEvent(OnHooksFeedEvent(HooksFeedEvent.ScrollCarouselToUnmute)) } else -> Unit } } Box { Scaffold( topBar = { ShortcutCarousel( shortcutCarouselController = vm.shortcutCarouselController, scrollBehavior = topBarBehavior, onRedirect = { onNavigationRequest(HooksHomeNavigationRequest.Redirect(it)) }, onOpenLikedSongs = { onNavigationRequest(HooksHomeNavigationRequest.LikedSongs) }, onOpenMySongs = { onNavigationRequest(HooksHomeNavigationRequest.MySongs(it)) }, onOpenFollowingSongs = { onNavigationRequest(HooksHomeNavigationRequest.FollowingClips) }, contentPadding = PaddingValues(top = SUNO_LOGO_TOP_BAR_HEIGHT.dp + SUNO_LOGO_CAROUSEL_SPACE.dp * 2), ) }, modifier = Modifier.nestedScroll(topBarBehavior.nestedScrollConnection), ) { innerPadding -> BoxWithConstraints( contentAlignment = Alignment.Center, modifier = Modifier .fillMaxWidth() .transparentTouchInterceptor( enabled = isExpandGestureEnabled, ) { coroutineScope.launch { topBarBehavior.animateClosed() } }, ) { val collapsedScale = remember(this.maxWidth) { // we want to scale down so that there is 12dp padding (on each side) (maxWidth - (2 * HOOKS_COLLAPSED_PADDING).dp) / maxWidth } Box( modifier = Modifier.fillMaxSize() .offset(y = innerPadding.calculateTopPadding()) .graphicsLayer { val scale = lerp(collapsedScale, 1f, topBarBehavior.state.collapsedFraction) scaleX = scale scaleY = scale }, ) { val feedState by vm.hooksFeedController.state.collectAsStateWithLifecycle() HooksFeedContent( vm = vm.hooksFeedController, pagerState = pagerState, onNavigationRequest = { onNavigationRequest(it.toHooksHomeNavigationRequest()) }, showHookOverlay = topBarBehavior.state.collapsedFraction > 0.9f, ) AnimatedVisibility( visible = !feedState.hasCompletedInitialLoad, enter = EnterTransition.None, exit = fadeOut(), ) { HooksFeedSkeleton() } AnimatedVisibility( visible = feedState.hasCompletedInitialLoad, enter = fadeIn(), exit = ExitTransition.None, ) { HooksMutedOverlay( modifier = Modifier .clip(RoundedCornerShape(36.dp)) .graphicsLayer { alpha = lerp(1f, 0f, topBarBehavior.state.collapsedFraction * 2) }, ) } } } } HooksCarouselTopBar( sunoLogoModifier = Modifier.pagerStateAlpha(pagerState), collapsedFraction = topBarBehavior.state.collapsedFraction, hasUnreadNotifications = viewState.hasUnreadNotifications == true, redirectToNotifications = { onNavigationRequest(HooksHomeNavigationRequest.Notifications) }, redirectToSearch = { onNavigationRequest(HooksHomeNavigationRequest.Search) }, modifier = Modifier.padding(horizontal = 20.dp).padding(top = SUNO_LOGO_CAROUSEL_SPACE.dp), ) } } @Composable private fun HooksCarouselTopBar( sunoLogoModifier: Modifier, collapsedFraction: Float, hasUnreadNotifications: Boolean, redirectToNotifications: () -> Unit, redirectToSearch: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .statusBarsPadding() .fillMaxWidth() .height(SUNO_LOGO_TOP_BAR_HEIGHT.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { SunoLogo(modifier = sunoLogoModifier) Row( modifier = Modifier.alpha(1f - collapsedFraction), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { CircleIconButton( vectorDrawableResId = CommonUiR.drawable.notifications, contentDescription = stringResource(CommonResR.string.notifications), showBadge = hasUnreadNotifications, onClick = redirectToNotifications, ) CircleIconButton( vectorDrawableResId = CommonUiR.drawable.magnifying_glass, contentDescription = stringResource(CommonResR.string.search), onClick = redirectToSearch, ) } } } private suspend fun TopAppBarScrollBehavior.animateToPercentCollapsed( percent: Float, ) = let { scrollBehavior -> coroutineScope { val animatable = Animatable( initialValue = scrollBehavior.state.heightOffset, ) var latestAnimatedValue = animatable.value animatable.animateTo( targetValue = scrollBehavior.state.heightOffsetLimit * percent, ) { if (scrollBehavior.state.heightOffset != latestAnimatedValue) { cancel() return@animateTo } latestAnimatedValue = this.value scrollBehavior.state.heightOffset = this.value } } } @Composable private fun SunoLogo( modifier: Modifier = Modifier, ) { Icon( painter = painterResource(id = CommonUiR.drawable.logo), contentDescription = null, tint = Color.White, modifier = modifier.height(22.dp), ) } @Composable private fun HooksHomeBottomSheet( state: HooksHomeState.BottomSheetState, onCreateSongSelected: (LocalClipData) -> Unit, onDismissRequest: (BottomSheetDismissalAction) -> Unit, modifier: Modifier = Modifier, ) { StateBottomSheet( state = state, isShowing = { it !is HooksHomeState.BottomSheetState.Hidden }, onDismissRequest = { onDismissRequest(BottomSheetDismissalAction.SwipeDown) }, modifier = modifier, ) { currentState -> when (currentState) { HooksHomeState.BottomSheetState.Hidden -> Unit is HooksHomeState.BottomSheetState.CreateSongSelect -> HooksSongSelectContent( controller = currentState.controller, onSongSelected = onCreateSongSelected, ) } } } @Composable fun HookCreateButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { PlayerActionButton( icon = painterResource(CommonUiR.drawable.plus), contentDescription = stringResource(CommonResR.string.create_hook), onClick = onClick, modifier = modifier, ) } private suspend fun TopAppBarScrollBehavior.animateClosed() = animateToPercentCollapsed(1f) private suspend fun TopAppBarScrollBehavior.animateOpen() = animateToPercentCollapsed(0f) // lifted from androids `ViewConfiguration.pointerSlop`, however we're not handling mouse slop atm private const val TOUCH_SLOP_DISTANCE_DP = 18 /*** * Listen for a touch down + touch up event, * but only consume the pointer input if the touch up event happens without a drag * * I.e. if a user drags on this element, pass the drag events to other listeners * if the user just touches the element, call onTouch and consume so no other listeners know about the touch */ private fun Modifier.transparentTouchInterceptor( enabled: Boolean = true, onTouch: () -> Unit, ) = pointerInput( // cancel/restart the detector when it would be enabled/disabled enabled, ) { if (!enabled) return@pointerInput val squaredSlop = (TOUCH_SLOP_DISTANCE_DP * TOUCH_SLOP_DISTANCE_DP).dp.toPx() awaitEachGesture { val down = awaitFirstDown(true, PointerEventPass.Initial) // loop until we go up, returning if touch slop is reached // only consume events on touch up while (true) { val event = awaitPointerEvent(PointerEventPass.Initial) val pointerChange = event.changes.find { it.id == down.id } ?: return@awaitEachGesture val totalSquaredDistance = (pointerChange.position.getDistanceSquared() - down.position.getDistanceSquared()).absoluteValue if (totalSquaredDistance > squaredSlop) { return@awaitEachGesture } if (pointerChange.changedToUpIgnoreConsumed()) { pointerChange.consume() break } } onTouch() } } @Composable private fun Modifier.pagerStateAlpha( pagerState: PagerState, ): Modifier { val alpha by remember { derivedStateOf { if (pagerState.currentPage > 1) { ALPHA_TRANSPARENT } else { val scrollProgress = pagerState.currentPage + pagerState.currentPageOffsetFraction (ALPHA_OPAQUE - scrollProgress).coerceIn(ALPHA_TRANSPARENT, ALPHA_OPAQUE) } } } return alpha(alpha) } private fun HooksFeedNavigationRequest.toHooksHomeNavigationRequest() = when (this) { is HooksFeedNavigationRequest.AssetEdit -> HooksHomeNavigationRequest.AssetEdit(this.clipId) is HooksFeedNavigationRequest.CreatorClick -> HooksHomeNavigationRequest.CreatorClick(this.creatorHandle) is HooksFeedNavigationRequest.Remix -> HooksHomeNavigationRequest.Remix(this.songId, this.task) is HooksFeedNavigationRequest.ReusePrompt -> HooksHomeNavigationRequest.ReusePrompt(this.promptForm) } @Composable private fun HooksFeedSkeleton() { val backgroundColor = ExtendedTheme.colors.backgroundTertiary val placeholderColor = Color.White.copy(alpha = 0.1f) // Large feed card skeleton Box( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(30.dp)) .background(backgroundColor), ) { // Placeholder pill at the top left Spacer( modifier = Modifier .padding(start = 22.dp, top = 22.dp) .width(71.dp) .height(20.dp) .clip(RoundedCornerShape(20.dp)) .background(shimmerBrush(placeholderColor)), ) } } @PreviewLightDark @Composable private fun HooksFeedSkeleton_Preview() { SunoPreview { HooksFeedSkeleton() } } @Preview @Composable private fun HooksCarouselTopBar_Preview() { SunoTheme { HooksCarouselTopBar( sunoLogoModifier = Modifier, collapsedFraction = 0f, hasUnreadNotifications = false, redirectToNotifications = {}, redirectToSearch = {}, ) } }