package com.suno.android.ui.screens.create.audio import android.content.res.Resources import android.icu.text.DateFormat import android.net.Uri import androidx.compose.runtime.Stable import androidx.media3.common.MediaItem import androidx.media3.common.Player import arrow.core.getOrElse import com.suno.android.common_core_utils.DispatcherIO import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.global_errors.DialogSpec import com.suno.android.common_core_utils.global_errors.TopLevelErrorManager import com.suno.android.common_core_utils.model.UiString import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.billing.mappers.toDurationRange import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.media.model.Waveform import com.suno.android.common_data.media.usecase.GetAudioDurationUseCase import com.suno.android.common_data.media.usecase.GetFileNameFromUriUseCase import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.repos.GenerationRepository import com.suno.android.common_data.upload.UploadFileAsClipWorker import com.suno.android.common_data.upload.UploadsRepository import com.suno.android.common_data.upload.model.UploadingFile import com.suno.android.common_data.upload.model.UserAudioFile import com.suno.android.common_media.extensions.seekTo import com.suno.android.common_mvi.MviController import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_res.R import com.suno.android.common_ui.components.waveform.AudioWaveformState import com.suno.android.gating.statsig.FeatureGate import com.suno.android.gating.statsig.StatsigFeatureDataSource import com.suno.android.media.Editor import com.suno.android.media.player.SharedPlayer import com.suno.android.ui.screens.create.audio.edit.AudioEditEvent import com.suno.android.ui.screens.create.audio.edit.AudioEditScreenController import com.suno.android.ui.screens.create.audio.model.AudioTaskParametersState import com.suno.android.ui.screens.create.audio.model.SeekingState import com.suno.android.ui.screens.create.audio.precondition.AudioPreconditionController import com.suno.android.ui.screens.create.audio.precondition.GetPreconditionStatusUseCase import com.suno.android.ui.screens.create.audio.recording.AudioRecordScreenController import com.suno.android.ui.screens.create.audio.recording.AudioRecordScreenEvent import com.suno.android.ui.screens.create.audio.task.extend.AudioExtendTaskController import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit @Stable class CreateAudioController @AssistedInject constructor( mviProcessorFactory: MviProcessorFactory, @Assisted coroutineScope: CoroutineScope, statsigManager: StatsigFeatureDataSource, private val audioRecordControllerFactory: AudioRecordScreenController.Factory, private val editControllerFactory: AudioEditScreenController.Factory, private val uploadsRepository: UploadsRepository, private val clipsRepository: ClipsRepository, private val genRepository: GenerationRepository, @Editor private val editPlayer: SharedPlayer, private val getFileNameFromUriUseCase: GetFileNameFromUriUseCase, private val resources: Resources, private val topLevelErrorManager: TopLevelErrorManager, private val audioPreconditionControllerFactory: AudioPreconditionController.Factory, private val getPreconditionStatusUseCase: GetPreconditionStatusUseCase, private val audioExtendTaskControllerFactory: AudioExtendTaskController.Factory, private val billingRepository: SunoBillingRepo, private val getAudioDurationUseCase: GetAudioDurationUseCase, @DispatcherIO private val ioDispatcher: CoroutineDispatcher, ) : MviController( processorFactory = mviProcessorFactory, coroutineScope = coroutineScope, initialState = CreateAudioState.None( configurations = CreateAudioState.Configurations( sourceOptions = buildList { if (statsigManager.checkGate(FeatureGate.CREATE_AUDIO_RECORD)) { add(CreateAudioState.AudioSourceOption.Record) } if (statsigManager.checkGate(FeatureGate.CREATE_AUDIO_UPLOAD)) { add(CreateAudioState.AudioSourceOption.Upload) } if (statsigManager.checkGate(FeatureGate.CREATE_AUDIO_LIBRARY)) { add(CreateAudioState.AudioSourceOption.Library) } }.toPersistentList(), createTaskOptions = buildList { if (statsigManager.checkGate(FeatureGate.CREATE_AUDIO_TASK_COVER)) { add(CreateAudioState.AudioCreateTask.Cover) } if (statsigManager.checkGate(FeatureGate.CREATE_AUDIO_TASK_EXTEND)) { add(CreateAudioState.AudioCreateTask.Extend) } }.toPersistentList(), ), ), ) { @AssistedFactory interface Factory { fun create( coroutineScope: CoroutineScope, ): CreateAudioController } private val audioLimits = billingRepository.billingStateFlow().map { it?.audioUploadLimits?.toDurationRange()?.getOrElse { error -> logger.e(error) null } }.filterNotNull().stateIn( scope = controllerScope, started = SharingStarted.Eagerly, initialValue = 3.seconds..1.minutes, ) override suspend fun reduceEvent( currentState: CreateAudioState, event: CreateAudioEvent, emitEffect: suspend (CreateAudioEffect) -> Unit, ): CreateAudioState { return when (event) { is CreateAudioEvent.OnLoadClipFromLocal -> { controllerScope.launch { val lyricsResponseDeferred = genRepository.getAlignedLyrics(event.clip.clipId).getOrNull() val waveform = lyricsResponseDeferred?.waveFormData?.let { Waveform(samples = it, duration = event.clip.duration ?: Duration.ZERO) } val updatedState = state.value.withWaveform(waveform) updateState { updatedState } } CreateAudioState.Completed( uploadFile = UploadingFile.NoFile, player = editPlayer.getInstance().apply { setMediaItem( MediaItem.fromUri(event.clip.mediaUrl.toUri()), ) repeatMode = Player.REPEAT_MODE_ONE prepare() }, clip = event.clip, configurations = currentState.configurations.copy( selectedCreateTask = event.createTask, ), waveform = null, taskParametersState = getInitialParameterStateForAudioCreateTask( task = event.createTask, ), ) } is CreateAudioEvent.OnAddAudioClick -> { if (currentState !is CreateAudioState.None) return currentState val sources = currentState.configurations.sourceOptions when (sources.size) { 0 -> currentState // no-op 1 -> currentState.withAudioSourceSelected(sources.first()) else -> { currentState.withBottomSheet( CreateAudioState.BottomSheetState.AudioCreateSourceSelect( audioSourceOptions = currentState.configurations.sourceOptions, onSourceSelected = { source -> controllerScope.launch { val updatedState = state.value.withAudioSourceSelected(source) updateState { updatedState } } }, ), ) } } } is CreateAudioEvent.OnAudioToUploadSelected -> { onAudioToUploadSelected(event.uri) currentState } CreateAudioEvent.DismissBottomSheet -> { val bottomSheetState = currentState.bottomSheetState when { bottomSheetState is CreateAudioState.BottomSheetState.Record && bottomSheetState.controller.state.value.isRecording -> { bottomSheetState.controller.sendEvent(AudioRecordScreenEvent.OnAttemptToDismissWhileRecording) currentState } bottomSheetState is CreateAudioState.BottomSheetState.Edit -> { showDiscardAudioConfirmationDialog() currentState } else -> { currentState.withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } } } CreateAudioEvent.OnDismissFailureClick -> { discardAudio() CreateAudioState.None( configurations = currentState.configurations, ) } CreateAudioEvent.OnCancelUploadClick -> { showCancelAudioConfirmationDialog() currentState } CreateAudioEvent.OnRemoveUploadClick -> { showRemoveAudioConfirmationDialog() currentState } CreateAudioEvent.OnChangeCreateTypeClick -> { currentState.withBottomSheet( CreateAudioState.BottomSheetState.AudioCreateTaskSelect( audioTasks = currentState.configurations.createTaskOptions, onTaskSelected = { newTask -> updateState { currentState -> currentState.withAudioCreateTask( task = newTask, ).withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } }, ), ) } CreateAudioEvent.OnPauseClick -> { editPlayer.getInstance().pause() currentState } CreateAudioEvent.OnPlayClick -> { editPlayer.getInstance().play() currentState } CreateAudioEvent.OnSeekStart -> { if (currentState !is CreateAudioState.Completed) return currentState val wasPlaying = currentState.player.isPlaying currentState.player.pause() currentState.copy( seekingState = SeekingState.Seeking(wasPlaying), ) } is CreateAudioEvent.OnSeekUpdate -> { if (currentState !is CreateAudioState.Completed) return currentState currentState.player.seekTo(event.newPosition) currentState } CreateAudioEvent.OnSeekStop -> { if (currentState !is CreateAudioState.Completed) return currentState if (currentState.seekingState !is SeekingState.Seeking) return currentState if (currentState.seekingState.wasPlaying) { currentState.player.play() } currentState.copy( seekingState = SeekingState.NotSeeking, ) } } } private data class PendingAudioUploadData( val userAudio: UserAudioFile, val workId: Id, ) private val pendingAudioUploadData = MutableStateFlow(null) private sealed interface AudioUpload { val uploadingFile: UploadingFile data class Uploading( override val uploadingFile: UploadingFile.Incomplete, ) : AudioUpload data class Uploaded( val waveform: Waveform?, override val uploadingFile: UploadingFile.Success, val clip: LocalClipData, ) : AudioUpload data class NoUpload( override val uploadingFile: UploadingFile.NoFile, ) : AudioUpload } private data class UserAudioUploadStatus( val userAudio: UserAudioFile, val uploadingFile: UploadingFile, ) val audioUploadUpdateJob = pendingAudioUploadData.onEach { if (it == null) { updateState { currentState -> CreateAudioState.None( configurations = currentState.configurations, ) } } }.filterNotNull().flatMapLatest { audioUploadData -> uploadsRepository.getFileUploadStatusFlow(audioUploadData.workId).map { uploadingFile -> UserAudioUploadStatus( userAudio = audioUploadData.userAudio, uploadingFile = uploadingFile, ) } }.onEach { if (it.uploadingFile is UploadingFile.Failure) { logger.e(it.uploadingFile.error) } }.map { userAudioUploadingFile -> val uploadingFile = userAudioUploadingFile.uploadingFile when (uploadingFile) { is UploadingFile.Incomplete -> AudioUpload.Uploading(uploadingFile) is UploadingFile.Success -> { val clip = clipsRepository.getClipById(uploadingFile.clipId).getOrNull() if (clip != null) { AudioUpload.Uploaded( waveform = userAudioUploadingFile.userAudio.waveform, uploadingFile = uploadingFile, clip = clip, ) } else { AudioUpload.Uploading( UploadingFile.Failure(IllegalStateException("failed to fetch clip after upload")), ) } } is UploadingFile.NoFile -> { AudioUpload.NoUpload(uploadingFile) } } }.onEach { uploadState -> updateState { currentState -> when (uploadState) { is AudioUpload.Uploaded -> CreateAudioState.Completed( uploadFile = uploadState.uploadingFile, player = editPlayer.getInstance().apply { setMediaItem( MediaItem.fromUri(uploadState.clip.mediaUrl.toUri()), ) repeatMode = Player.REPEAT_MODE_ONE prepare() }, clip = uploadState.clip, configurations = currentState.configurations, waveform = uploadState.waveform, taskParametersState = getInitialParameterStateForAudioCreateTask( task = currentState.configurations.selectedCreateTask, ), ) is AudioUpload.Uploading -> when (uploadState.uploadingFile) { is UploadingFile.Working -> { CreateAudioState.Uploading( uploadFile = uploadState.uploadingFile, configurations = currentState.configurations, ) } UploadingFile.Cancelled -> CreateAudioState.None( configurations = currentState.configurations, bottomSheetState = currentState.bottomSheetState, ) is UploadingFile.Failure -> CreateAudioState.Failed( configurations = currentState.configurations, ) } is AudioUpload.NoUpload -> currentState } } }.launchIn(controllerScope) override fun onClear() { super.onClear() editPlayer.releaseInstance() (state.value.bottomSheetState as? CreateAudioState.BottomSheetState.Edit)?.controller?.onClear() } private suspend fun CreateAudioState.withAudioSourceSelected( source: CreateAudioState.AudioSourceOption, ): CreateAudioState { return when (source) { CreateAudioState.AudioSourceOption.Record -> withPreconditionOrStartOfOption( AudioPreconditionController.Flow.Record, ) CreateAudioState.AudioSourceOption.Upload -> withPreconditionOrStartOfOption( AudioPreconditionController.Flow.Upload, ) CreateAudioState.AudioSourceOption.Library -> TODO() } // update after running precondition to avoid blocking state flow } private fun CreateAudioState.withAudioCreateTask( task: CreateAudioState.AudioCreateTask, ): CreateAudioState { val newConfiguration = configurations.copy( selectedCreateTask = task, ) return when (this) { is CreateAudioState.Completed -> this.copy( configurations = newConfiguration, taskParametersState = getInitialParameterStateForAudioCreateTask(task), ) is CreateAudioState.Failed -> this.copy( configurations = newConfiguration, ) is CreateAudioState.None -> this.copy( configurations = newConfiguration, ) is CreateAudioState.Uploading -> this.copy( configurations = newConfiguration, ) } } private fun getInitialParameterStateForAudioCreateTask( task: CreateAudioState.AudioCreateTask, ): AudioTaskParametersState = when (task) { CreateAudioState.AudioCreateTask.Cover -> AudioTaskParametersState.Cover CreateAudioState.AudioCreateTask.Extend -> AudioTaskParametersState.Extend( audioExtendTaskControllerFactory.create( coroutineScope = controllerScope, player = editPlayer.getInstance(), ), ) } private fun CreateAudioState.withBottomSheet( bottomSheetState: CreateAudioState.BottomSheetState, ): CreateAudioState { (this.bottomSheetState as? CreateAudioState.BottomSheetState.HasController)?.controller?.onClear() return when (this) { is CreateAudioState.Completed -> this.copy(bottomSheetState = bottomSheetState) is CreateAudioState.Failed -> this.copy(bottomSheetState = bottomSheetState) is CreateAudioState.None -> this.copy(bottomSheetState = bottomSheetState) is CreateAudioState.Uploading -> this.copy(bottomSheetState = bottomSheetState) } } private fun CreateAudioState.withWaveform( waveform: Waveform?, ): CreateAudioState = when (this) { is CreateAudioState.Completed -> this.copy(waveform = waveform) is CreateAudioState.Failed -> this is CreateAudioState.None -> this is CreateAudioState.Uploading -> this } private fun showDiscardAudioConfirmationDialog() = controllerScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.discard_audio_upload_title), body = UiString.Resource(R.string.discard_audio_upload_body), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.discard_audio_upload_cancel), onClick = { dismiss -> dismiss() }, ), DialogSpec.Button( label = UiString.Resource(R.string.discard_audio_upload_confirm), onClick = { dismiss -> discardAudio() updateState { currentState -> currentState.withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } dismiss() }, ), ), ), ) } private fun showCancelAudioConfirmationDialog() = controllerScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.cancel_audio_upload_title), body = UiString.Resource(R.string.cancel_audio_upload_body), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.cancel_audio_upload_cancel), onClick = { dismiss -> dismiss() }, ), DialogSpec.Button( label = UiString.Resource(R.string.cancel_audio_upload_confirm), onClick = { dismiss -> discardAudio() updateState { currentState -> CreateAudioState.None( configurations = currentState.configurations, ) } dismiss() }, ), ), ), ) } private fun showRemoveAudioConfirmationDialog() = controllerScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.remove_audio_upload_title), body = UiString.Resource(R.string.remove_audio_upload_body), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.remove_audio_upload_cancel), onClick = { dismiss -> dismiss() }, ), DialogSpec.Button( label = UiString.Resource(R.string.remove_audio_upload_confirm), onClick = { dismiss -> discardAudio() updateState { currentState -> CreateAudioState.None( configurations = currentState.configurations, ) } dismiss() }, ), ), ), ) } private fun discardAudio() { pendingAudioUploadData.value?.let { pendingData -> uploadsRepository.cancelUpload(pendingData.workId) pendingAudioUploadData.compareAndSet(pendingData, null) } editPlayer.releaseInstance() } fun resetAudioController() { discardAudio() updateState { currentState -> CreateAudioState.None( configurations = currentState.configurations, ) } } private suspend fun CreateAudioState.withPreconditionOrStartOfOption( flow: AudioPreconditionController.Flow, ): CreateAudioState { val currentState = this val status = getPreconditionStatusUseCase(flow.preconditions) val source = when (flow) { AudioPreconditionController.Flow.Upload -> CreateAudioState.AudioSourceOption.Upload AudioPreconditionController.Flow.Record -> CreateAudioState.AudioSourceOption.Record } return if (status.all { it.value }) { currentState.withStartOfOption(source) } else { currentState.withBottomSheet( bottomSheetState = CreateAudioState.BottomSheetState.Precondition( controller = audioPreconditionControllerFactory.create( coroutineScope = controllerScope, flow = flow, onAllPreconditionsComplete = { updateState { it.withStartOfOption(source) } }, ), ), ) } } private fun CreateAudioState.withStartOfOption( audioSourceOption: CreateAudioState.AudioSourceOption, ) = when (audioSourceOption) { CreateAudioState.AudioSourceOption.Record -> this.withBottomSheet( bottomSheetState = CreateAudioState.BottomSheetState.Record( controller = audioRecordControllerFactory.create( coroutineScope = controllerScope, onRecordingComplete = ::onAudioRecordControllerComplete, ), ), ) CreateAudioState.AudioSourceOption.Upload -> withBeginUpload() CreateAudioState.AudioSourceOption.Library -> TODO() } private suspend fun showTooShortError( source: UserAudioFile.Source, minimumLimit: Duration, ) { updateState { state -> state.withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.audio_create_error_short_title), body = when (source) { UserAudioFile.Source.Recording -> UiString.Resource( R.string.audio_create_error_short_body_record, // todo localize the duration minimumLimit.toString(), ) UserAudioFile.Source.Upload -> UiString.Resource( R.string.audio_create_error_short_body_upload, // todo localize the duration minimumLimit.toString(), ) }, buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.ok), type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) } private fun onAudioToUploadSelected( uri: Uri, ) = controllerScope.launch { val duration = withContext(ioDispatcher) { getAudioDurationUseCase(uri).getOrNull() } if (duration == null) { showUnreadableAudioError() return@launch } val audioLimits = audioLimits.value if (duration < audioLimits.start) { showTooShortError( source = UserAudioFile.Source.Upload, minimumLimit = audioLimits.start, ) return@launch } updateState { it.withBottomSheet( CreateAudioState.BottomSheetState.Edit( controller = editControllerFactory.create( coroutineScope = controllerScope, audio = UserAudioFile( uri = uri, source = UserAudioFile.Source.Upload, ), timeLimits = audioLimits, onEditCancelled = ::onEditUploadCancel, onEditComplete = ::onEditComplete, ), ), ) } } private suspend fun showUnreadableAudioError() { updateState { state -> state.withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } topLevelErrorManager.broadcastTopLevelDialogError( DialogSpec( title = UiString.Resource(R.string.audio_create_error_unreadable_title), body = UiString.Resource(R.string.audio_create_error_unreadable_body), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.ok), type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) } private fun onAudioRecordControllerComplete( file: UserAudioFile, waveformState: AudioWaveformState, ) = controllerScope.launch { val duration = waveformState.duration val audioLimits = audioLimits.value if (duration < audioLimits.start) { showTooShortError( source = UserAudioFile.Source.Recording, minimumLimit = audioLimits.start, ) return@launch } updateState { it.withBottomSheet( CreateAudioState.BottomSheetState.Edit( controller = editControllerFactory.create( coroutineScope = controllerScope, audio = file, waveformState = waveformState, timeLimits = audioLimits, onEditCancelled = ::onEditRecordCancel, onEditComplete = ::onEditComplete, ), ), ) } } private fun onEditRecordCancel() = controllerScope.launch { updateState { it.withBottomSheet( bottomSheetState = CreateAudioState.BottomSheetState.Record( controller = audioRecordControllerFactory.create( coroutineScope = controllerScope, onRecordingComplete = ::onAudioRecordControllerComplete, ), ), ) } } private fun onEditUploadCancel() = controllerScope.launch { updateState { currentState -> currentState.withBeginUpload() } } private fun onEditComplete( userAudio: UserAudioFile, duration: Duration, ) = controllerScope.launch { val uploadName = when (userAudio.source) { UserAudioFile.Source.Recording -> getNameForRecording(duration) UserAudioFile.Source.Upload -> getFileNameFromUriUseCase(userAudio.uri) ?: resources.getString(R.string.uploaded_file) } uploadToServer(userAudio, uploadName) } private fun getNameForRecording( duration: Duration, ): String { val dateFormat = DateFormat.getInstanceForSkeleton( DateFormat.ABBR_MONTH_DAY, ) val timeFormat = DateFormat.getInstanceForSkeleton( DateFormat.HOUR_MINUTE, ) val currentTime = System.currentTimeMillis() val formattedDuration = duration.toString(DurationUnit.SECONDS, 1) return resources.getString( R.string.recorded_file_name, formattedDuration, dateFormat.format(currentTime), timeFormat.format(currentTime), ) } private fun CreateAudioState.withBeginUpload(): CreateAudioState { emitEffect(CreateAudioEffect.RequestMusicPicker) return this.withBottomSheet( CreateAudioState.BottomSheetState.Hidden, ) } private suspend fun uploadToServer( userAudio: UserAudioFile, name: String, ) { val workId = uploadsRepository.uploadAudioAsClip( localFile = userAudio.uri, name = name, ) val initialStatus = uploadsRepository.getFileUploadStatusFlow(workId).firstOrNull() if (initialStatus != null && initialStatus !is UploadingFile.Failure) { pendingAudioUploadData.value = PendingAudioUploadData( userAudio = userAudio, workId = workId, ) updateState { currentState -> currentState.withBottomSheet(CreateAudioState.BottomSheetState.Hidden) } } else { val sheetState = state.value.bottomSheetState as? CreateAudioState.BottomSheetState.Edit ?: return // sheet closed sheetState.controller.sendEvent(AudioEditEvent.OnSaveFailed) } } fun stopAudioPlayback() { (state.value as? CreateAudioState.Completed)?.player?.stop() } }