package com.suno.android.ui.screens.share_asset import android.content.ContentResolver import android.content.ContentValues import android.os.Environment import android.os.Looper import android.provider.MediaStore import androidx.annotation.OptIn import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.PlayerMessage import androidx.media3.exoplayer.SeekParameters import androidx.navigation.toRoute import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.model.UiString import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_data.mappers.share_asset.ShareAssetStatus import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.repos.GenerationRepository import com.suno.android.common_data.use_case.ProcessLyricsForDisplayUseCase import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.media.Editor import com.suno.android.media.MediaManager import com.suno.android.media.VideoPreview import com.suno.android.ui.screens.navigation.NavDestination import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch import okio.IOException import java.net.URL import javax.inject.Inject import kotlin.math.pow import kotlin.random.Random import kotlin.time.Duration.Companion.INFINITE import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds private const val MAXIMUM_CONSECUTIVE_GENERATION_FAILURES = 5 @HiltViewModel class ShareAssetEditorScreenVM @Inject constructor( processorFactory: MviProcessorFactory, savedStateHandle: SavedStateHandle, @Editor private val audioPlayer: ExoPlayer, @VideoPreview val videoPlayer: Lazy, private val clipsRepository: ClipsRepository, private val genRepository: GenerationRepository, private val processLyricsForDisplayUseCase: ProcessLyricsForDisplayUseCase, mediaManager: MediaManager, private val contentResolver: ContentResolver, ) : MviViewModel( processorFactory = processorFactory, initialState = ShareAssetEditorScreenState.Default, ) { val songId: Id = savedStateHandle.toRoute().songId.let(::Id) init { mediaManager.getPlayer().pause() fetchSong() } override fun onCleared() { super.onCleared() audioPlayer.stop() audioPlayer.release() } override suspend fun reduceEvent( currentState: ShareAssetEditorScreenState, event: ShareAssetEditorScreenEvent, emitEffect: suspend (ShareAssetEditorScreenEffect) -> Unit, ): ShareAssetEditorScreenState = when (event) { ShareAssetEditorScreenEvent.RetrySongLoad -> { fetchSong() ShareAssetEditorScreenState.FetchingSong } is ShareAssetEditorScreenEvent.ReadyOnlyEvent -> reduceReadyOnlyEvent(currentState, event, emitEffect) } private fun reduceReadyOnlyEvent( currentState: ShareAssetEditorScreenState, event: ShareAssetEditorScreenEvent.ReadyOnlyEvent, emitEffect: suspend (ShareAssetEditorScreenEffect) -> Unit, ): ShareAssetEditorScreenState { if (currentState !is ShareAssetEditorScreenState.Ready) return currentState return when (event) { is ShareAssetEditorScreenEvent.SelectDurationOption -> { val prevDuration = currentState.selectedRangeDuration.duration val newDuration = event.option.duration val newStartTime = (currentState.rangeStartTime + (prevDuration - newDuration) / 2).coerceIn( minimumValue = ZERO, maximumValue = audioPlayer.duration.takeIf { it > 0L }?.milliseconds?.let { it - currentState.selectedRangeDuration.duration } ?: INFINITE, ) currentState.copy( rangeStartTime = newStartTime, selectedRangeDuration = event.option, ).also { updatePlayerToState(it) } } ShareAssetEditorScreenEvent.SeekStarted -> { audioPlayer.pause() currentState.copy(isSeeking = true) } is ShareAssetEditorScreenEvent.SeekTo -> { currentState.copy( rangeStartTime = event.newStartTime.coerceIn( minimumValue = ZERO, maximumValue = audioPlayer.duration.takeIf { it > 0L }?.milliseconds?.let { it - currentState.selectedRangeDuration.duration } ?: INFINITE, ), ) } ShareAssetEditorScreenEvent.SeekCompleted -> { updatePlayerToState(currentState) currentState.copy( isSeeking = false, ) } ShareAssetEditorScreenEvent.SubmitClick -> { submitClip(currentState) currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.Submitted, ) } is ShareAssetEditorScreenEvent.GenerationEvent -> reduceGenerationEvents( currentState, event, emitEffect, ) } } private fun reduceGenerationEvents( currentState: ShareAssetEditorScreenState.Ready, event: ShareAssetEditorScreenEvent.GenerationEvent, emitEffect: suspend (ShareAssetEditorScreenEffect) -> Unit, ): ShareAssetEditorScreenState = when (event) { ShareAssetEditorScreenEvent.CancelGeneration -> currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.NotGenerating, ) is ShareAssetEditorScreenEvent.VideoDownloaded -> { currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.NotGenerating, ) } } private fun fetchSong() = viewModelScope.launch { val songDeferred = async { clipsRepository.getClipById(songId) .map { it?.xAsSongListDataOrNull() } .getOrNull() } val lyricsResponseDeferred = async { genRepository.getAlignedLyrics(songId).getOrNull() } val lyricsProcessedDeferred = async { val lyrics = lyricsResponseDeferred.await() ?: return@async persistentListOf() processLyricsForDisplayUseCase(lyrics.alignedWords).toPersistentList() } val song = songDeferred.await() ?: run { updateState { ShareAssetEditorScreenState.LoadingError } return@launch } val waveform = lyricsResponseDeferred.await()?.waveFormData ?: FloatArray(1000) { Random.nextFloat() } val lyrics = lyricsProcessedDeferred.await() updateState { currentState -> when (currentState) { is ShareAssetEditorScreenState.Ready -> currentState.copy( song = song, lyrics = lyrics, ) else -> { val videoPlayer = song.videoCoverUrl?.let { videoPlayer.get().apply { setMediaItem( MediaItem.fromUri(it.toUri()), ) repeatMode = Player.REPEAT_MODE_ALL playWhenReady = true prepare() } } ShareAssetEditorScreenState.Ready( song = song, lyrics = lyrics, audioPlayer = audioPlayer, videoPlayer = videoPlayer, waveform = waveform, ) } }.also { audioPlayer.setMediaItem( MediaItem.fromUri(song.mediaUrl.toUri()), ) audioPlayer.playWhenReady = true audioPlayer.prepare() updatePlayerToState(it) } } } private val messages = mutableSetOf() @OptIn(UnstableApi::class) private fun updatePlayerToState( state: ShareAssetEditorScreenState.Ready, ) { val startTime = state.rangeStartTime val endTime = state.rangeStartTime + state.selectedRangeDuration.duration audioPlayer.playWhenReady = true audioPlayer.setSeekParameters(SeekParameters.EXACT) // setting a clipping configuration makes it impossible to retrieve the media duration, // set a message to loop instead messages.forEach { it.cancel() } messages.clear() audioPlayer.seekTo(startTime.inWholeMilliseconds) audioPlayer.createMessage { _, _ -> audioPlayer.seekTo(startTime.inWholeMilliseconds) audioPlayer.play() } // by default, messages run on the playback thread. Seeking on that thread will cause a deadlock .setLooper(Looper.getMainLooper()).setPosition(endTime.inWholeMilliseconds).setDeleteAfterDelivery(false) .send().also { messages.add(it) } audioPlayer.play() } private fun submitClip( state: ShareAssetEditorScreenState.Ready, ) { viewModelScope.launch(Dispatchers.IO) { val response = genRepository.startShareAssetGeneration( clipId = state.song.id, startTime = state.rangeStartTime, endTime = state.endTime, ).getOrElse { logger.e(it) updateState { currentState -> if (currentState !is ShareAssetEditorScreenState.Ready) return@updateState currentState currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.NotGenerating, ) } emitEffect( ShareAssetEditorScreenEffect.ShowError( UiString.Resource(com.suno.android.common_res.R.string.generating_share_video_error), ), ) return@launch } updateState { currentState -> if (currentState !is ShareAssetEditorScreenState.Ready) return@updateState currentState currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.Generating, ) } var status = response var consecutiveFailures = 0 while (status !is ShareAssetStatus.Complete) { logger.println { status } delay(2.seconds * 2.0.pow(consecutiveFailures.toDouble())) status = genRepository.getShareAssetStatus( clipId = state.song.id, shareAssetId = status.id, ).onSuccess { consecutiveFailures = 0 }.getOrElse { error -> logger.e(error) consecutiveFailures++ if (error is IOException && consecutiveFailures >= MAXIMUM_CONSECUTIVE_GENERATION_FAILURES) { updateState { currentState -> if (currentState !is ShareAssetEditorScreenState.Ready) return@updateState currentState currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.NotGenerating, ) } emitEffect( ShareAssetEditorScreenEffect.ShowError( UiString.Resource(com.suno.android.common_res.R.string.generating_share_video_error), ), ) return@launch } else { status } } } downloadClip(state, status) updateState { currentState -> if (currentState !is ShareAssetEditorScreenState.Ready) return@updateState currentState currentState.copy( generatingState = ShareAssetEditorScreenState.GeneratingState.NotGenerating, ) } } } private fun downloadClip( state: ShareAssetEditorScreenState.Ready, asset: ShareAssetStatus.Complete, ) { viewModelScope.launch(Dispatchers.IO) { val contentValues = ContentValues().apply { put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/Suno Shared Videos") put(MediaStore.Video.Media.DISPLAY_NAME, state.song.title ?: "Suno Video") val type = contentResolver.getType(asset.url.toUri()) put(MediaStore.Video.Media.MIME_TYPE, type ?: "video/mp4") } val uri = contentResolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues) if (uri == null) return@launch URL(asset.url.url).openStream().use { input -> contentResolver.openOutputStream(uri)?.use { output -> input.copyTo(output) } } emitEffect( ShareAssetEditorScreenEffect.ShareVideoAsset(uri), ) } } }