package com.suno.android.ui.screens.songdetails import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.common_analytics.managers.AnalyticsManager 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_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.repos.GenerationRepository import com.suno.android.common_data.use_case.UriToBase64DataUseCase import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.remote.entities.CaptionsMentionsSpec import com.suno.android.common_networking.remote.entities.ClipMetadataSpec import com.suno.android.common_networking.remote.entities.MentionSchema import com.suno.android.common_networking.remote.entities.SimpleProfileInfoSchema import com.suno.android.common_networking.remote.entities.UserSearchRequest import com.suno.android.common_networking.remote.search.SearchService import com.suno.android.common_res.R import com.suno.android.common_ui.components.list_items.comments.MentionState import com.suno.android.common_ui.extensions.xGetCurrentlyEditingWord import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.media.MediaMetadataManager import com.suno.android.media.MediaVisibility import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import javax.inject.Inject private val defaultState = SongDetailsUiState( songTitle = "", songImageUrl = "", songCaptionInputValue = TextFieldValue(), songLyrics = "", allowComments = true, allowRemix = true, isCaptionMentionsGateEnabled = false, ) @HiltViewModel class SongDetailsViewModel @Inject constructor( processorFactory: MviProcessorFactory, private val clipsRepository: ClipsRepository, private val generationRepository: GenerationRepository, private val mediaMetadataManager: MediaMetadataManager, private val analyticsManager: AnalyticsManager, private val featureManager: FeatureManager, private val topLevelErrorManager: TopLevelErrorManager, private val searchService: SearchService, private val uriToBase64DataUseCase: UriToBase64DataUseCase, ) : MviViewModel( processorFactory = processorFactory, initialState = defaultState, ) { private var currentSongId: Id? = null // Used to diff against current state to detect changes private var originalState = defaultState private var updateJob: Job? = null private var updateAllowCommentsJob: Job? = null private var postSongJob: Job? = null private var promptImageJob: Job? = null fun setSongId( songId: Id, ) { // Reset state in case of artifacts updateState { defaultState } currentSongId = songId viewModelScope.launch { val clip = clipsRepository.getClipById(songId) .getOrElse { error -> logger.e(error) return@launch } ?: return@launch val captionMentionStates = (clip.captionMentions?.userMentions ?: emptyList()).map { MentionState( start = it.start, end = it.end, handle = it.handle, displayName = it.displayName, ) } val isCaptionMentionsGateEnabled = featureManager.hasFeature(Feature.CaptionMentions) val isEditSongArtGateEnabled = featureManager.hasFeature(Feature.EditSongArt) updateState { oldState -> val newState = oldState.copy( songTitle = clip.nowPlayingTitle ?: "", songImageUrl = clip.albumImageUrl?.url ?: "", songCaptionInputValue = TextFieldValue(clip.caption ?: ""), captionMentions = captionMentionStates, songLyrics = clip.displayLyrics ?: "", allowComments = clip.allowComments == true, allowRemix = clip.canRemix == true, isCaptionMentionsGateEnabled = isCaptionMentionsGateEnabled, editSongArtFlagEnabled = isEditSongArtGateEnabled, ) originalState = newState newState } } } fun findMentionsByHandle( query: String, authorHandle: UserHandle?, ) { // prioritize handles - users who have already been mentioned, as well as the author of the song val mentionHandles = state.value.captionMentions.map { it.handle } + listOfNotNull(authorHandle?.handle) val searchQuery = UserSearchRequest( term = query, boostedUserHandles = mentionHandles, ) searchService.postUserSearch(userSearchRequest = searchQuery) .onEach { response -> val body = response.body() val users = body ?: emptyList() updateState { it.copy(captionMentionTargets = users) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } fun addMentionToComment( mention: SimpleProfileInfoSchema, ) { if (mention.handle == null) return // handle multiple mentions of a single user val displayNameWithFallback = mention.displayName?.ifEmpty { mention.handle } ?: mention.handle as String val currentlyEditingWord = state.value.songCaptionInputValue.xGetCurrentlyEditingWord() val updatedContent = state.value.songCaptionInputValue.text.replace(currentlyEditingWord, displayNameWithFallback) val mentionRegex = Regex(displayNameWithFallback) val handleMatches = mentionRegex.findAll(updatedContent) val prevMentions = state.value.captionMentions.filter { it.handle == mention.handle }.size val match = handleMatches.elementAt(prevMentions) val mentionSchema = MentionState( start = match.range.first, end = match.range.last + 1, handle = mention.handle ?: "", displayName = mention.displayName ?: "", ) val updatedTextFieldValue = state.value.songCaptionInputValue.copy( text = updatedContent, selection = TextRange(match.range.last + 1), ) updateState { oldState -> val newMentions = oldState.captionMentions.plus(mentionSchema) oldState.copy( captionMentions = newMentions, songCaptionInputValue = updatedTextFieldValue, captionMentionTargets = emptyList(), ) } } override suspend fun reduceEvent( currentState: SongDetailsUiState, event: SongDetailsMviEvent, emitEffect: suspend (SongDetailsEffect) -> Unit, ): SongDetailsUiState = when (event) { is SongDetailsMviEvent.BackPress -> currentState.copy(screen = SongDetailsScreenType.Main) is SongDetailsMviEvent.EditSongCaptionInputChanged -> { val newCaption = if (event.textFieldValue.text.length <= currentState.songCaptionLimit) { event.textFieldValue } else { event.textFieldValue.text.substring( 0, currentState.songCaptionLimit, ) } if (!state.value.isCaptionMentionsGateEnabled) { currentState.copy( songCaptionInputValue = event.textFieldValue, songTextFieldsHaveChanges = newCaption != originalState.songCaptionInputValue.text, ) } val currentEditingWord = event.textFieldValue.xGetCurrentlyEditingWord() val isSearchingForMentions = currentEditingWord.contains("@") if (isSearchingForMentions) { val searchQuery = currentEditingWord.substringAfter("@") findMentionsByHandle(searchQuery, null) } // check if mentions need to be repositioned/removed based on changes to content val mentions = currentState.captionMentions val updatedMentions = mentions.mapNotNull { mention -> // find the nth instance of each specific mention for each handle and update val mentionsOfHandle = mentions.filter { it.handle == mention.handle } val mentionIndex = mentionsOfHandle.indexOf(mention) val matches = Regex(mention.displayName).findAll(event.textFieldValue.text) try { matches.elementAt(mentionIndex).let { match -> mention.copy(start = match.range.first, end = match.range.last + 1) } } catch (e: Exception) { null } } currentState.copy( songCaptionInputValue = event.textFieldValue, songTextFieldsHaveChanges = newCaption != originalState.songCaptionInputValue.text, captionMentionTargets = if (!isSearchingForMentions) { emptyList() } else { currentState.captionMentionTargets }, captionMentions = updatedMentions, ) } is SongDetailsMviEvent.EditSongCaptionInputFocused -> { /** logging only */ currentState } is SongDetailsMviEvent.EditSongLyricsClick -> { currentState.copy(screen = SongDetailsScreenType.Lyrics) } is SongDetailsMviEvent.EditSongArtClick -> { currentState.copy(screen = SongDetailsScreenType.SongArt) } is SongDetailsMviEvent.EditSongArtPrompt -> { if (promptImageJob == null) { promptImageJob = viewModelScope.launch { val string = generationRepository.promptSongImage( prompt = event.prompt, ) string.getOrNull()?.let { url -> updateState { it.copy(songImageUrl = url, songArtHasChanged = true) } } updateState { it.copy(isImageGenInProgress = false) } promptImageJob = null } } currentState.copy(isImageGenInProgress = true) } is SongDetailsMviEvent.EditSongArtUpload -> { viewModelScope.launch { uriToBase64DataUseCase(event.uri)?.let { base64Data -> updateState { currentState.copy(songArtHasChanged = true, songImageUrl = base64Data) } } } currentState } is SongDetailsMviEvent.EditSongArtSubmit -> { updateSong( isInPostingMode = event.isInPostingMode, imageUrl = currentState.songImageUrl, ) currentState.copy( isSaveInProgress = true, ) } is SongDetailsMviEvent.EditSongLyricsInputChange -> { currentState.copy( songLyrics = event.text, songLyricsHasChanges = event.text != originalState.songLyrics, ) } is SongDetailsMviEvent.EditSongLyricsSubmit -> { updateSong( isInPostingMode = event.isInPostingMode, lyrics = currentState.songLyrics, ) currentState.copy( isSaveInProgress = true, ) } is SongDetailsMviEvent.EditSongTitleInputChanged -> { currentState.copy( songTitle = event.text, songTextFieldsHaveChanges = event.text != originalState.songTitle, ) } is SongDetailsMviEvent.EditSongTitleInputFocused -> { /** logging only */ currentState } is SongDetailsMviEvent.EditSongTitleCaptionSaveClick -> { updateSong( isInPostingMode = event.isInPostingMode, title = currentState.songTitle, caption = currentState.songCaptionInputValue.text, captionMentions = currentState.captionMentions, ) currentState.copy( isSaveInProgress = true, ) } is SongDetailsMviEvent.MoreOptionsClick -> { currentState.copy(screen = SongDetailsScreenType.MoreOptions) } is SongDetailsMviEvent.SongAllowCommentsToggled -> { updateAllowComments(event.enabled) currentState.copy(allowComments = event.enabled) } SongDetailsMviEvent.PostSongSubmitClicked -> { postSong() currentState } is SongDetailsMviEvent.ConfirmChangesDialogDismissClick -> { /** logging only */ currentState } is SongDetailsMviEvent.ShowPermissionRationaleDialog -> { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.permission_required), body = UiString.Resource(R.string.camera_permission_required), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.ok), type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) currentState } is SongDetailsMviEvent.ShowConfirmChangeDialog -> { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.song_details_unsaved_changes_dialog_title), body = UiString.Resource(R.string.song_details_unsaved_changes_dialog), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.cancel), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), DialogSpec.Button( label = UiString.Resource(R.string.discard), onClick = { dismissDialog -> viewModelScope.launch { emitEffect(SongDetailsEffect.HandleBack(forceBack = true)) } dismissDialog() }, type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) currentState } } private fun updateSong( isInPostingMode: Boolean, title: String? = null, caption: String? = null, lyrics: String? = null, captionMentions: List = emptyList(), imageUrl: String? = null, ) { currentSongId?.let { nonNullSongId -> var spec = ClipMetadataSpec() // Only adds fields to request body if specifically requested to be updated // to avoid unnecessary updates and overriding unsynced updates from other platforms title?.let { if (title != originalState.songTitle) spec = spec.copy(title = title) } caption?.let { if (caption != originalState.songCaptionInputValue.text) spec = spec.copy(caption = caption) } lyrics?.let { if (lyrics != originalState.songLyrics) spec = spec.copy(lyrics = lyrics) } imageUrl?.let { if (imageUrl != originalState.songImageUrl) spec = spec.copy(imageUrl = imageUrl) } if (spec != ClipMetadataSpec()) { updateJob?.cancel() updateJob = viewModelScope.launch { clipsRepository.updateMetadata( nonNullSongId, ClipMetadataSpec( title = title, caption = caption, captionMentions = CaptionsMentionsSpec( userMentions = captionMentions.map { MentionSchema( start = it.start, end = it.end, handle = it.handle, displayName = it.displayName, ) }, ), lyrics = lyrics, imageUrl = imageUrl, ), ).collect { result -> updateJob = null val response = result.getOrNull() if (result.isSuccess && response != null) { originalState = originalState.copy( songTitle = response.title ?: originalState.songTitle, songCaptionInputValue = if (response.caption != null) { TextFieldValue( response.caption ?: "", ) } else { originalState.songCaptionInputValue }, songTextFieldsHaveChanges = false, songLyrics = response.prompt ?: originalState.songLyrics, songLyricsHasChanges = false, songImageUrl = response.imageUrl ?: imageUrl ?: originalState.songImageUrl, songArtHasChanged = false, ) updateState { oldState -> oldState.copy( songTitle = response.title ?: oldState.songTitle, songCaptionInputValue = if (response.caption != null) { TextFieldValue( response.caption ?: "", ) } else { oldState.songCaptionInputValue }, songTextFieldsHaveChanges = false, songLyrics = response.prompt ?: oldState.songLyrics, songLyricsHasChanges = false, songImageUrl = response.imageUrl ?: imageUrl ?: oldState.songImageUrl, songArtHasChanged = false, isSaveInProgress = false, ) } } else { updateState { oldState -> oldState.copy( isSaveInProgress = false, ) } } handleUpdateSongResponseAnalytics( isInPostingMode = isInPostingMode, success = result.isSuccess, title = title, caption = caption, lyrics = lyrics, imageUrl = imageUrl, ) } } } handleUpdateSongAnalytics( isInPostingMode = isInPostingMode, title = title, caption = caption, lyrics = lyrics, imageUrl = imageUrl, ) } } private fun handleUpdateSongAnalytics( isInPostingMode: Boolean, title: String? = null, caption: String? = null, lyrics: String? = null, imageUrl: String? = null, ) { val source = if (isInPostingMode) { SongDetailsAnalytics.SCREEN_NAME_POST } else { SongDetailsAnalytics.SCREEN_NAME_EDIT } val analyticsMetadata = Json.encodeToString( mapOf( "clipId" to currentSongId?.value, ), ) if (title != null && title != originalState.songTitle) { val event = if (title.isEmpty()) { SongDetailsAnalytics.EventName.TITLE_DELETED } else if (originalState.songTitle.isEmpty()) { SongDetailsAnalytics.EventName.TITLE_ADDED } else { SongDetailsAnalytics.EventName.TITLE_UPDATED } analyticsManager.trackMviEvent( eventName = event, source = source, context = analyticsMetadata, ) } if (caption != null && caption != originalState.songCaptionInputValue.text) { val event = if (caption.isEmpty()) { SongDetailsAnalytics.EventName.CAPTION_DELETED } else if (originalState.songCaptionInputValue.text.isEmpty()) { SongDetailsAnalytics.EventName.CAPTION_ADDED } else { SongDetailsAnalytics.EventName.CAPTION_UPDATED } analyticsManager.trackMviEvent( eventName = event, source = source, context = analyticsMetadata, ) } if (lyrics != null && lyrics != originalState.songLyrics) { val event = if (lyrics.isEmpty()) { SongDetailsAnalytics.EventName.DISPLAYED_LYRICS_DELETED } else if (originalState.songLyrics.isEmpty()) { SongDetailsAnalytics.EventName.DISPLAYED_LYRICS_ADDED } else { SongDetailsAnalytics.EventName.DISPLAYED_LYRICS_UPDATED } analyticsManager.trackMviEvent( eventName = event, source = source, context = analyticsMetadata, ) } if (imageUrl != null && imageUrl != originalState.songImageUrl) { val event = if (imageUrl.isEmpty()) { SongDetailsAnalytics.EventName.IMAGE_URL_DELETED } else if (originalState.songImageUrl.isEmpty()) { SongDetailsAnalytics.EventName.IMAGE_URL_ADDED } else { SongDetailsAnalytics.EventName.IMAGE_URL_UPDATED } analyticsManager.trackMviEvent( eventName = event, source = source, context = analyticsMetadata, ) } } private fun handleUpdateSongResponseAnalytics( success: Boolean, isInPostingMode: Boolean, title: String? = null, caption: String? = null, lyrics: String? = null, imageUrl: String? = null, ) { val source = if (isInPostingMode) { SongDetailsAnalytics.SCREEN_NAME_POST } else { SongDetailsAnalytics.SCREEN_NAME_EDIT } val analyticsMetadata = Json.encodeToString( mapOf( "clipId" to currentSongId?.value, ), ) if (title != null || caption != null) { val event = if (success) { SongDetailsAnalytics.EventName.SAVE_SONG_DETAILS_SUCCEEDED } else { SongDetailsAnalytics.EventName.SAVE_SONG_DETAILS_FAILED } analyticsManager.trackMviEffect( effectName = event, source = source, context = analyticsMetadata, ) } if (lyrics != null) { val event = if (success) { SongDetailsAnalytics.EventName.EDIT_DISPLAYED_LYRICS_SUCCEEDED } else { SongDetailsAnalytics.EventName.EDIT_DISPLAYED_LYRICS_FAILED } analyticsManager.trackMviEffect( effectName = event, source = source, context = analyticsMetadata, ) } if (imageUrl != null) { val event = if (success) { SongDetailsAnalytics.EventName.EDIT_IMAGE_URL_SUCCEEDED } else { SongDetailsAnalytics.EventName.EDIT_IMAGE_URL_FAILED } analyticsManager.trackMviEffect( effectName = event, source = source, context = analyticsMetadata, ) } } private fun updateAllowComments( enabled: Boolean, ) { currentSongId?.let { songId -> updateAllowCommentsJob?.cancel() updateAllowCommentsJob = viewModelScope.launch { clipsRepository.updateAllowComments(clipId = songId, allowComments = enabled).collect { updateState { oldState -> oldState.copy(allowComments = it.canComment) } updateAllowCommentsJob = null } } } } private fun postSong() { // Updates title and caption in case there are unsaved changes updateSong( isInPostingMode = true, title = state.value.songTitle, caption = state.value.songCaptionInputValue.text, captionMentions = state.value.captionMentions, ) currentSongId?.let { songId -> if (postSongJob == null) { postSongJob = viewModelScope.launch { clipsRepository.setClipVisibility( clipId = songId, isPublic = true, ).catch { exception -> val analyticsMetadata = Json.encodeToString( mapOf( "clipId" to songId.value, ), ) emitEffect( SongDetailsEffect.PostSongFailure( exception.message ?: "", ), ) analyticsManager.trackMviEvent( eventName = SongDetailsAnalytics.EventName.POST_SONG_FAILURE, source = SongDetailsAnalytics.SCREEN_NAME_POST, context = analyticsMetadata, ) postSongJob = null }.collect { val analyticsMetadata = Json.encodeToString( mapOf( "clipId" to songId.value, ), ) if (it.isSuccess && it.getOrNull()?.isPublic == true) { emitEffect(SongDetailsEffect.PostSongSuccess) mediaMetadataManager.broadcastMediaVisibility( MediaVisibility( clipId = songId, isPublic = true, ), ) analyticsManager.trackMviEvent( eventName = SongDetailsAnalytics.EventName.POST_SONG_SUCCESS, source = SongDetailsAnalytics.SCREEN_NAME_POST, context = analyticsMetadata, ) } else { emitEffect( SongDetailsEffect.PostSongFailure( it.exceptionOrNull()?.message ?: "", ), ) analyticsManager.trackMviEvent( eventName = SongDetailsAnalytics.EventName.POST_SONG_FAILURE, source = SongDetailsAnalytics.SCREEN_NAME_POST, context = analyticsMetadata, ) } postSongJob = null } } } } } fun logError( throwable: Throwable, ) { logger.e(throwable) } }