package com.suno.android.ui.screens.create.audio.edit import android.os.Looper import androidx.annotation.OptIn import androidx.compose.runtime.Stable import androidx.core.net.toUri 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 com.suno.android.common_data.media.model.Waveform import com.suno.android.common_data.media.usecase.GetAudioWaveformUseCase import com.suno.android.common_data.media.usecase.TrimAudioUseCase 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_ui.components.waveform.AudioWaveformState import com.suno.android.common_ui.components.waveform.DisplayMethod import com.suno.android.media.Editor import com.suno.android.ui.screens.create.audio.model.SeekingState import dagger.Lazy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.File import kotlin.time.Duration @Stable @OptIn(UnstableApi::class) class AudioEditScreenController @AssistedInject constructor( mviProcessorFactory: MviProcessorFactory, @Assisted coroutineScope: CoroutineScope, @Assisted private val audio: UserAudioFile, @Assisted waveformState: AudioWaveformState?, // todo disallow this @Assisted timeLimits: ClosedRange, @Assisted private val onEditCancelled: () -> Unit, @Assisted private val onEditComplete: (UserAudioFile, Duration) -> Unit, @Editor private val audioPlayer: ExoPlayer, private val trimUseCase: TrimAudioUseCase, private val getAudioWaveformUseCase: Lazy, ) : MviController( processorFactory = mviProcessorFactory, coroutineScope = coroutineScope, initialState = AudioEditState( audio = audio, cropState = if (waveformState != null) { AudioEditState.CropState.Loaded( audioWaveform = waveformState, cropTime = Duration.ZERO..waveformState.duration, ) } else { AudioEditState.CropState.Loading }, timeLimits = timeLimits, player = audioPlayer, ), ) { @AssistedFactory interface Factory { fun create( coroutineScope: CoroutineScope, audio: UserAudioFile, waveformState: AudioWaveformState? = null, timeLimits: ClosedRange, onEditCancelled: () -> Unit, onEditComplete: (UserAudioFile, Duration) -> Unit, ): AudioEditScreenController } init { audioPlayer.setSeekParameters( SeekParameters.EXACT, ) controllerScope.launch { state.filter { it.cropState is AudioEditState.CropState.Loading }.onEach { val waveform = getAudioWaveformUseCase.get().invoke( uri = state.value.audio.uri, ).getOrElse { logger.e(it) return@onEach } updateState { currentState -> currentState.copy( cropState = AudioEditState.CropState.Loaded( audioWaveform = AudioWaveformState( waveform = waveform.samples, duration = waveform.duration, displayMethod = DisplayMethod.ExponentialInterpolation, ), cropTime = Duration.ZERO..waveform.duration, ), ) } }.launchIn(controllerScope) } } override suspend fun reduceEvent( currentState: AudioEditState, event: AudioEditEvent, emitEffect: suspend (AudioEditEffect) -> Unit, ): AudioEditState { return when (event) { AudioEditEvent.OnPlay -> { currentState.player.play() currentState } AudioEditEvent.OnPause -> { currentState.player.pause() currentState } is AudioEditEvent.OnEditCropUpdate -> { if (currentState.cropState !is AudioEditState.CropState.Loaded) { return currentState } currentState.player.pause() currentState.copy( cropState = currentState.cropState.copy( cropTime = event.newCropTime, ), ) } AudioEditEvent.OnEditCropCommit -> { updatePlayerToState(currentState) currentState } AudioEditEvent.OnSeekStart -> { val wasPlaying = currentState.player.isPlaying currentState.player.pause() currentState.copy( seekingState = SeekingState.Seeking( wasPlaying = wasPlaying, ), ) } is AudioEditEvent.OnSeekUpdate -> { currentState.player.seekTo(event.newPosition) currentState } AudioEditEvent.OnSeekStop -> { if (currentState.seekingState !is SeekingState.Seeking) return currentState if (currentState.seekingState.wasPlaying) { currentState.player.play() } currentState.copy( seekingState = SeekingState.NotSeeking, ) } AudioEditEvent.OnStartOverClick -> { onEditCancelled() currentState } AudioEditEvent.OnSaveClick -> { saveRecording( audio = currentState.audio, trim = (currentState.cropState as? AudioEditState.CropState.Loaded)?.cropTime ?: Duration.ZERO..Duration.INFINITE, ) currentState } AudioEditEvent.OnSaveFailed -> { // todo show error toast currentState.copy( isSaving = false, ) } } } private val messages = mutableSetOf() @OptIn(UnstableApi::class) private fun updatePlayerToState( state: AudioEditState, ) { val audioPlayer = state.player audioPlayer.playWhenReady = false (audioPlayer as? ExoPlayer)?.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() val cropTime = (state.cropState as? AudioEditState.CropState.Loaded)?.cropTime if (cropTime == null) { audioPlayer.repeatMode = Player.REPEAT_MODE_ONE } else { val startTime = cropTime.start val endTime = cropTime.endInclusive audioPlayer.seekTo(startTime.inWholeMilliseconds) (audioPlayer as? ExoPlayer)?.apply { 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) } } } } private var saveJob: Job? = null private fun saveRecording( audio: UserAudioFile, trim: ClosedRange, ) { saveJob?.cancel() saveJob = controllerScope.launch { updateState { currentState -> currentState.copy( isSaving = true, ) } // todo retrieve this in a more stable way val loadedCropState = state.value.cropState as? AudioEditState.CropState.Loaded val originalDuration = loadedCropState?.audioWaveform?.duration // if user has not applied a trim, just upload the original file val fileToUpload = if (trim.start == Duration.ZERO && (originalDuration == null || trim.endInclusive >= originalDuration) ) { audio } else { val trimmedFile = File.createTempFile( "trimmed", ".wav", ).apply { deleteOnExit() } trimUseCase( inputFile = audio.uri, trim = trim, outputFile = trimmedFile, ).onFailure { logger.e(it) // todo show error updateState { currentState -> currentState.copy( isSaving = false, ) } return@launch } audio.copy( uri = trimmedFile.toUri(), ) } val fileToUploadWithWaveform = loadedCropState?.let { fileToUpload.copy( waveform = Waveform( samples = loadedCropState.audioWaveform.waveform, duration = loadedCropState.audioWaveform.duration, ), ) } onEditComplete( fileToUploadWithWaveform ?: fileToUpload, trim.endInclusive - trim.start, ) } } init { audioPlayer.apply { playWhenReady = false setMediaItem( MediaItem.fromUri(state.value.audio.uri), ) prepare() updatePlayerToState(state.value) } } override fun onClear() { super.onClear() audioPlayer.release() } }