package com.suno.android.ui.screens.share_asset.components import android.icu.text.DateFormat import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight 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.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember 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.layout.ContentScale import androidx.compose.ui.platform.LocalDensity 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.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.media3.common.Player import coil3.compose.AsyncImage import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.use_case.ProcessLyricsForDisplayUseCase import com.suno.android.common_ui.components.preview.SunoPreview import com.suno.android.common_ui.theme.EditorialFontFamily import com.suno.android.common_ui.theme.ExtendedTheme import com.suno.android.common_ui.theme.InputSansFontFamily import com.suno.android.common_ui.theme.NeueMontrealFontFamily import com.suno.android.omniplayer.VideoPlayerCover import com.suno.android.preview.FakeLyrics import com.suno.android.preview.FakePlayer import com.suno.android.preview.FakeSongListData import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import com.suno.android.common_res.R as CommonResR @Composable internal fun VideoClipPreview( clipStartTime: Duration, clipEndTime: Duration, playingTime: Duration, totalDuration: Duration, song: SongListData, lyrics: ImmutableList, videoPlayer: Player?, modifier: Modifier = Modifier, ) { Box( contentAlignment = Alignment.Center, modifier = modifier .aspectRatio(9 / 16f) .fillMaxWidth() .clip(RoundedCornerShape(32.dp)), ) { BoxWithConstraints( modifier = Modifier.fillMaxSize(), ) { if (videoPlayer != null) { VideoPlayerCover( exoPlayer = videoPlayer, ) } else { AsyncImage( model = song.displayImageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) } Spacer( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = .5f)), ) SongInfo( song = song, playingTime = playingTime, totalDuration = totalDuration, modifier = Modifier .align(Alignment.TopCenter) .padding(top = maxHeight * .1f), ) LyricsOverlay( clipStartTime = clipStartTime, clipEndTime = clipEndTime, playingTime = playingTime, lyrics = lyrics, modifier = Modifier .fillMaxWidth(.8f) .align(Alignment.Center), ) SunoLogo( modifier = Modifier .align(Alignment.BottomCenter) .padding(bottom = maxHeight * .1f) .fillMaxHeight(0.025f), ) } } } @Composable private fun SongInfo( song: SongListData, playingTime: Duration, totalDuration: Duration, modifier: Modifier, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier, ) { Text( text = song.title ?: "", textAlign = TextAlign.Center, style = TextStyle( fontFamily = EditorialFontFamily, fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.sp, color = Color.White, ), ) Spacer(Modifier.height(4.dp)) Text( text = song.handle?.handle?.let { stringResource(CommonResR.string.share_video_preview_artist, it) } ?: "", textAlign = TextAlign.Center, style = TextStyle( fontFamily = NeueMontrealFontFamily, fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp, color = Color.White.copy(alpha = .5f), ), ) Spacer(Modifier.height(18.dp)) val formatter = remember { DateFormat.getInstanceForSkeleton(DateFormat.MINUTE_SECOND) } val playingTimeFormatted = remember(playingTime / 1000) { formatter.format(playingTime.inWholeMilliseconds) } val totalDurationFormatted = remember(totalDuration / 1000) { formatter.format(totalDuration.inWholeMilliseconds) } Text( text = "$playingTimeFormatted / $totalDurationFormatted", textAlign = TextAlign.Center, style = TextStyle( fontFamily = InputSansFontFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp, color = Color.White, ), ) } } // arrived at by a lot of guess and check comparing to backend output. Feel free to change private const val LYRICS_FONT_SCALE_FACTOR = 0.024 // copied from iOS LyricsOverlay code private const val LYRICS_FONT_LINE_HEIGHT_FACTOR = 1.5f private const val LYRICS_MAX_LINES = 4 @Composable private fun LyricsOverlay( clipStartTime: Duration, clipEndTime: Duration, playingTime: Duration, lyrics: ImmutableList, modifier: Modifier = Modifier, ) { BoxWithConstraints( modifier = modifier, ) { val density = LocalDensity.current val fontSize = remember(density) { with(density) { // divide by fontScale to undo font scaling and get a consistent text size (maxWidth.toPx() * LYRICS_FONT_SCALE_FACTOR / fontScale).sp } } val baseStyle = ExtendedTheme.typography.lyricsMode.copy( color = Color.White, textAlign = TextAlign.Center, fontSize = fontSize, lineHeight = fontSize * LYRICS_FONT_LINE_HEIGHT_FACTOR, ) val textMeasurer = rememberTextMeasurer() val pagesState = remember(textMeasurer, lyrics, this.constraints) { val state = LyricsPageState() val words = lyrics.flatMap { it.words } val startIndex = words.indexOfLast { it.startTime <= clipStartTime } + 1 if (startIndex >= words.size) return@remember state for (word in words.subList(startIndex, words.size)) { if (word.startTime > clipEndTime) break val doesFitOnPage = textMeasurer.measure( text = state.testCurrentPage(word), style = baseStyle, constraints = constraints, maxLines = LYRICS_MAX_LINES, ).run { !didOverflowWidth && !didOverflowHeight } if (doesFitOnPage) { state.addChunkToCurrentPage(word) } else { state.addChunkToNextPage(word) } } state } val currentPage by remember(pagesState, playingTime) { derivedStateOf { pagesState.pages.firstOrNull { it.startTime < playingTime && it.endTime > playingTime } ?: pagesState.pages.firstOrNull() } } Text( text = buildAnnotatedString { val page = currentPage ?: return@buildAnnotatedString val nextWordIndex = page.chunks.indexOfFirst { it.startTime > playingTime }.takeUnless { it == -1 } ?: page.chunks.size for (activeWord in page.chunks.subList(0, nextWordIndex)) { append(activeWord.text) append(" ") } if (nextWordIndex >= page.chunks.size) { return@buildAnnotatedString } pushStyle( SpanStyle(color = Color.White.copy(alpha = .6f)), ) for (futureWord in page.chunks.subList(nextWordIndex, page.chunks.size)) { append(futureWord.text) append(" ") } }, style = baseStyle, ) } } @Stable private class LyricsPageState { val pages: MutableList = mutableListOf(Page()) val currentPage: Page get() = pages.last() data class Page( val chunks: MutableList = mutableListOf(), ) { val startTime get() = chunks.firstOrNull()?.startTime ?: Duration.ZERO val endTime get() = chunks.lastOrNull()?.endTime ?: Duration.ZERO } fun nextPage() { pages.add(Page()) } fun testCurrentPage( chunk: ProcessLyricsForDisplayUseCase.WordToken, ): String = currentPage.chunks.joinToString(" ") { it.text } + " " + chunk.text fun addChunkToCurrentPage( chunk: ProcessLyricsForDisplayUseCase.WordToken, ) { currentPage.chunks.add(chunk) } fun addChunkToNextPage( chunk: ProcessLyricsForDisplayUseCase.WordToken, ) { nextPage() addChunkToCurrentPage(chunk) } } @Composable private fun SunoLogo( modifier: Modifier, ) { Image( painter = painterResource(com.suno.android.common_ui.R.drawable.logo), contentDescription = null, modifier = modifier, ) } @PreviewLightDark @Composable private fun VideoClipPreview_VideoCover_Preview() { SunoPreview { VideoClipPreview( clipStartTime = 10.seconds, clipEndTime = 40.seconds, playingTime = 15.seconds, totalDuration = 3.minutes, song = FakeSongListData.cat, lyrics = ProcessLyricsForDisplayUseCase().invoke(FakeLyrics.cat.alignedWords).toImmutableList(), videoPlayer = FakePlayer(), ) } } @PreviewLightDark @Composable private fun VideoClipPreview_ImageCover_Preview() { SunoPreview { VideoClipPreview( clipStartTime = 10.seconds, clipEndTime = 40.seconds, playingTime = 15.seconds, totalDuration = 3.minutes, song = FakeSongListData.cat, lyrics = ProcessLyricsForDisplayUseCase().invoke(FakeLyrics.cat.alignedWords).toImmutableList(), videoPlayer = null, ) } }