package com.suno.android.ui.screens.hooks.overlay import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString 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 com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.mappers.hooks.Lyrics import com.suno.android.common_ui.components.player.PlayerActionButton import com.suno.android.common_ui.components.text.ExpandableText import com.suno.android.common_ui.extensions.xAsFormattedCount import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.NeueMontrealFontFamily import com.suno.android.common_ui.theme.SunoTheme import com.suno.android.preview.FakeLocalHookData import com.suno.android.ui.screens.hooks.feed.HookPlayState import com.suno.android.ui.screens.hooks.overlay.capsule.SongCapsule import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUiR private const val COLLAPSED_CAPTION_MAX_LINES = 2 @Composable fun HookOverlay( hook: LocalHookData, playState: HookPlayState, playerPosition: Duration, isCaptionExpanded: Boolean, isClipSaved: Boolean, isCreatorHidden: Boolean, onVideoClick: () -> Unit, onLikeButtonClick: () -> Unit, onDoubleTapLike: (Offset) -> Unit, onCommentsButtonClick: () -> Unit, onFollowCreatorClick: () -> Unit, onShareButtonClick: () -> Unit, onMoreMenuClick: () -> Unit, onAddClipToPlaylistClick: (LocalClipData) -> Unit, onCreatorClick: (handle: UserHandle?) -> Unit, onSongCapsuleClick: () -> Unit, onRemixButtonClick: (() -> Unit)?, onCaptionClick: () -> Unit, currentUserHandle: UserHandle?, ) { val hapticFeedback = LocalHapticFeedback.current val density = LocalDensity.current val heartAnimationState = rememberHeartAnimationState() Box( modifier = Modifier .fillMaxSize() .pointerInput(hook.hookId, hook.currentUserLiked) { detectTapGestures( onTap = { hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) onVideoClick() }, onDoubleTap = { offset -> heartAnimationState.addHeart(offset) hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) onDoubleTapLike(offset) }, ) }, ) { if (!isCreatorHidden) { HookContentOverlay( playState = playState, onVideoClick = onVideoClick, hook = hook, playerPosition = playerPosition, currentUserHandle = currentUserHandle, onCreatorClick = onCreatorClick, onFollowCreatorClick = onFollowCreatorClick, isCaptionExpanded = isCaptionExpanded, onCaptionClick = onCaptionClick, onRemixButtonClick = onRemixButtonClick, onLikeButtonClick = onLikeButtonClick, onCommentsButtonClick = onCommentsButtonClick, onShareButtonClick = onShareButtonClick, onMoreMenuClick = onMoreMenuClick, isClipSaved = isClipSaved, onAddClipToPlaylistClick = onAddClipToPlaylistClick, onSongCapsuleClick = onSongCapsuleClick, ) } heartAnimationState.activeHearts.forEach { heartAnimation -> key(heartAnimation.id) { AnimatedHeart( modifier = Modifier .offset( x = with(density) { (heartAnimation.position.x - 32.dp.toPx()).toDp() }, y = with(density) { (heartAnimation.position.y - 32.dp.toPx()).toDp() }, ), onAnimationComplete = { heartAnimationState.removeHeart(heartAnimation.id) }, ) } } AnimatedVisibility( visible = isCreatorHidden, enter = fadeIn(), exit = fadeOut(), ) { CreatorHiddenOverlay() } } } @Composable private fun BoxScope.HookContentOverlay( playState: HookPlayState, onVideoClick: () -> Unit, hook: LocalHookData, playerPosition: Duration, currentUserHandle: UserHandle?, onCreatorClick: (UserHandle?) -> Unit, onFollowCreatorClick: () -> Unit, isCaptionExpanded: Boolean, onCaptionClick: () -> Unit, onRemixButtonClick: (() -> Unit)?, onLikeButtonClick: () -> Unit, onCommentsButtonClick: () -> Unit, onShareButtonClick: () -> Unit, onMoreMenuClick: () -> Unit, isClipSaved: Boolean, onAddClipToPlaylistClick: (LocalClipData) -> Unit, onSongCapsuleClick: () -> Unit, ) { AnimatedVisibility( visible = playState == HookPlayState.UserPaused, enter = fadeIn(), exit = fadeOut(), modifier = Modifier.align(Alignment.Center), ) { PlayButtonOverlay(onPlayClick = onVideoClick) } Column( modifier = Modifier .align(Alignment.BottomStart) .padding(horizontal = 16.dp) .padding(bottom = 8.dp), ) { Row( modifier = Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceBetween, ) { Column( modifier = Modifier .padding(start = 8.dp) .weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp), ) { if (hook.lyricDisplayType is LocalHookData.LyricDisplayType.BottomLeft) { LyricsSection( lyrics = hook.lyrics, playerPosition = playerPosition, ) } HookCreator( creator = hook.creator, showFollowButton = !hook.currentUserFollowsCreator && currentUserHandle != hook.creator.handle, onCreatorAttributionClick = onCreatorClick, onFollowCreatorClick = onFollowCreatorClick, ) hook.caption.takeIf { it.isNotBlank() } ?.let { caption -> ExpandableText( text = caption, color = ExtendedTheme.colors.textPrimary, style = ExtendedTheme.typography.smallBody, maxLines = COLLAPSED_CAPTION_MAX_LINES, isExpanded = isCaptionExpanded, onExpandToggleRequest = onCaptionClick, ) } } HookPlayerButtons( hook = hook, onRemixButtonClick = onRemixButtonClick, onLikeButtonClick = onLikeButtonClick, onCommentsButtonClick = onCommentsButtonClick, onShareButtonClick = onShareButtonClick, onMoreMenuClick = onMoreMenuClick, ) } val isPlaying = playState == HookPlayState.Playing || playState == HookPlayState.Muted SongCapsule( clip = hook.clip, isPlaying = isPlaying, isClipSaved = isClipSaved, onAddClipToPlaylistClick = { onAddClipToPlaylistClick(hook.clip) }, onSongCapsuleClick = onSongCapsuleClick, ) } } @Composable private fun PlayButtonOverlay( modifier: Modifier = Modifier, onPlayClick: () -> Unit, ) { FilledIconButton( onClick = onPlayClick, colors = IconButtonDefaults.filledIconButtonColors( containerColor = Color.Black.copy(alpha = 0.40f), contentColor = Color.White, ), modifier = modifier.size(58.dp), ) { Icon( painter = painterResource(id = CommonUiR.drawable.play), contentDescription = stringResource(CommonResR.string.play), modifier = Modifier.size(42.dp), ) } } /** * Vertical stack of action buttons for hook interactions - like, comment, share, and overflow menu. */ @Composable private fun HookPlayerButtons( hook: LocalHookData, onRemixButtonClick: (() -> Unit)?, onLikeButtonClick: () -> Unit, onCommentsButtonClick: () -> Unit, onShareButtonClick: () -> Unit, onMoreMenuClick: () -> Unit, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { val context = LocalContext.current if (onRemixButtonClick != null) { PlayerActionButton( icon = painterResource(CommonUiR.drawable.remix), text = stringResource(CommonResR.string.remix), contentDescription = stringResource(CommonResR.string.remix), onClick = onRemixButtonClick, ) } HookLikeButton( hook = hook, onLikeButtonClick = onLikeButtonClick, ) val formattedCommentCount = remember(hook.commentCount) { if (hook.commentCount == 0) { context.getString(CommonResR.string.comment) } else { hook.commentCount.xAsFormattedCount() } } PlayerActionButton( icon = painterResource(CommonUiR.drawable.comments_v2), contentDescription = stringResource(CommonResR.string.comments), text = formattedCommentCount, onClick = { onCommentsButtonClick() }, ) PlayerActionButton( icon = painterResource(CommonUiR.drawable.share_arrow), text = stringResource(CommonResR.string.share), contentDescription = stringResource(CommonResR.string.share), onClick = onShareButtonClick, ) PlayerActionButton( icon = painterResource(CommonUiR.drawable.ellipsis_horizontal_v2), contentDescription = stringResource(CommonResR.string.settings), onClick = onMoreMenuClick, ) } } @Composable fun LyricsSection( lyrics: Lyrics?, playerPosition: Duration, modifier: Modifier = Modifier, baseStyle: TextStyle = TextStyle( fontFamily = NeueMontrealFontFamily, fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold, color = ExtendedTheme.colors.textPrimary, ), ) { if (lyrics == null) return val line = remember(playerPosition, lyrics) { // todo set up a binary search to find the containing line more quickly lyrics.lines.lastOrNull { playerPosition > it.startTime } } // optimize recompositions by keeping recomposition from occuring if line + index are the same val firstFutureWordIndex by remember(line, playerPosition) { derivedStateOf { // todo set up a binary search to find the future word more quickly line?.words?.indexOfFirst { it.startTime > playerPosition }?.takeUnless { it == -1 } } } val text = remember(line, firstFutureWordIndex) { buildAnnotatedString { line?.words?.forEachIndexed { index, word -> if (index == firstFutureWordIndex) { pushStyle( SpanStyle( color = baseStyle.color.copy(alpha = .8f), ), ) } append(word.text) if (index != line.words.lastIndex) append(" ") } } } Text( text = text, style = baseStyle, modifier = modifier, ) } @Preview @Composable private fun HookOverlay_Preview() { SunoTheme { HookOverlay( hook = FakeLocalHookData.synth, playState = HookPlayState.Playing, isCaptionExpanded = false, playerPosition = 2.seconds, isClipSaved = false, isCreatorHidden = false, onVideoClick = {}, onLikeButtonClick = {}, onDoubleTapLike = {}, onCommentsButtonClick = {}, onFollowCreatorClick = {}, onShareButtonClick = {}, onMoreMenuClick = {}, onAddClipToPlaylistClick = {}, onCreatorClick = {}, onSongCapsuleClick = {}, onRemixButtonClick = {}, onCaptionClick = {}, currentUserHandle = UserHandle("gordon_ramsey"), ) } }