package com.suno.android.ui.bottom_sheets.song_actions import android.app.DownloadManager import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.clip.UpdateClipReactionUseCase import com.suno.android.clip.UpdateClipRemixabilityUseCase import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.constants.ReactionType 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.Url import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.generation.SongGenerationStateStore import com.suno.android.common_data.mappers.clips.ClipStatus import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.repos.ClipAction import com.suno.android.common_data.repos.ClipMetricsRepository import com.suno.android.common_data.repos.DownloadsRepo import com.suno.android.common_data.repos.ShareLinkRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.extensions.xGetErrorResponse import com.suno.android.common_networking.extensions.xGetRemasterModel import com.suno.android.common_networking.extensions.xHasFeatureAccess import com.suno.android.common_networking.remote.captcha_check.CaptchaCheckService import com.suno.android.common_networking.remote.entities.CheckCaptchaRequest import com.suno.android.common_networking.remote.entities.ClipMetadataSpec import com.suno.android.common_networking.remote.entities.ClipVisibilitySpec import com.suno.android.common_networking.remote.entities.FeedbackSpec import com.suno.android.common_networking.remote.entities.FlagSpec import com.suno.android.common_networking.remote.entities.GenUpsampleSpec import com.suno.android.common_networking.remote.entities.TrashSpec import com.suno.android.common_networking.remote.entities.UsagePlanFeatureNames import com.suno.android.common_networking.remote.gen.ClipVisibilityResponseEntity import com.suno.android.common_networking.remote.gen.GenService import com.suno.android.common_networking.remote.generate.GenerateService import com.suno.android.common_res.R import com.suno.android.common_ui.components.bottom_sheet.SharePlatformConstants 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 com.suno.android.media.hooks.toRecommendationMetadata import com.suno.android.review.AppReviewManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import retrofit2.HttpException import javax.inject.Inject @HiltViewModel class SongActionsTrayVM @Inject constructor( processorFactory: MviProcessorFactory, private val userSessionRepository: UserSessionRepository, private val genService: GenService, private val appReviewManager: AppReviewManager, private val mediaMetadataManager: MediaMetadataManager, private val analyticsManager: AnalyticsManager, private val featureManager: FeatureManager, private val topLevelErrorManager: TopLevelErrorManager, private val captchaCheckService: CaptchaCheckService, private val songGenerationStateStore: SongGenerationStateStore, private val generateService: GenerateService, private val shareLinkRepository: ShareLinkRepository, private val billingRepo: SunoBillingRepo, private val downloadsRepo: DownloadsRepo, private val clipsMetricsRepository: ClipMetricsRepository, private val updateClipRemixabilityUseCase: UpdateClipRemixabilityUseCase, private val updateClipReactionUseCase: UpdateClipReactionUseCase, ) : MviViewModel< SongActionBottomSheetScreenEvent, SongActionBottomSheetScreenState, SongActionBottomSheetScreenEffect, >( processorFactory = processorFactory, initialState = SongActionBottomSheetScreenState( isLoading = true, hasCreditsLeft = true, isDownloadsGateEnabled = featureManager.hasFeature(Feature.Downloads), isRemixGateEnabled = featureManager.hasFeature(Feature.RemixClip), isDownloadDisableGateEnabled = featureManager.hasFeature(Feature.ClipDisableDownloads), currentViewer = userSessionRepository.sessionConfigurationStateFlow().value.user, ), ) { init { upstreamFlows .bind( source = userSessionRepository.sessionConfigurationStateFlow(), map = { session -> SongActionBottomSheetScreenEvent.Internal.OnSessionConfigUserLoaded( user = session.user, ) }, onErrorEvent = SongActionBottomSheetScreenEvent.Error::GenericError, ) upstreamFlows .bind( source = billingRepo.billingStateFlow(), map = { billingState -> SongActionBottomSheetScreenEvent.Internal.OnBillingStateLoaded( billingInfo = billingState, ) }, onErrorEvent = SongActionBottomSheetScreenEvent.Error::GenericError, ) // TODO hack to be able to show a snack for song publish, replace with global snackbar mechanism mediaMetadataManager .mediaVisibilityFlow() .onEach { if (it.clipId == state.value.currentSongId) { emitEffect( SongActionBottomSheetScreenEffect.OnSongVisibilityChange( isPublic = it.isPublic, ), ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } override suspend fun reduceEvent( currentState: SongActionBottomSheetScreenState, event: SongActionBottomSheetScreenEvent, emitEffect: suspend (SongActionBottomSheetScreenEffect) -> Unit, ): SongActionBottomSheetScreenState { return when (event) { is SongActionBottomSheetScreenEvent.NavigateToShareVideo -> { emitEffect( SongActionBottomSheetScreenEffect.NavigateToShareVideo( songId = event.songId, ), ) currentState } is SongActionBottomSheetScreenEvent.OnRemixClicked -> { emitEffect( SongActionBottomSheetScreenEffect.NavigateToRemix( songId = event.songId, task = event.task, ), ) currentState } is SongActionBottomSheetScreenEvent.OnReusePromptClicked -> { currentState } is SongActionBottomSheetScreenEvent.OnSongDeleted -> { currentState } is SongActionBottomSheetScreenEvent.OnSongRenamed -> { currentState } is SongActionBottomSheetScreenEvent.OnSongUndoDeleted -> { currentState } is SongActionBottomSheetScreenEvent.OnSongUndoReported -> { currentState } SongActionBottomSheetScreenEvent.OnUpgradeClicked -> { currentState } is SongActionBottomSheetScreenEvent.OnSongReported -> { currentState } is SongActionBottomSheetScreenEvent.OnReuseTapped -> { currentState } is SongActionBottomSheetScreenEvent.OnRemasterClicked -> { currentState } is SongActionBottomSheetScreenEvent.OnRemasterTapped -> { viewModelScope.launch { try { val actionRequiresToken = captchaCheckService.checkCaptchaIsRequired( CheckCaptchaRequest(captchaType = "generation"), ).body()?.required logger.d { "generate actionRequiresToken: $actionRequiresToken" } val upsampleParamsSpec = GenUpsampleSpec( clipId = event.songId.value, modelName = event.modelExternalKey, ) val response = generateService.runUpsampleGeneration(upsampleParamsSpec) if (response.isSuccessful) { val clipStatuses = response.body()?.clips ?.associate { clip -> Id(clip.id) to ClipStatus.fromString(clip.status) } ?: emptyMap() songGenerationStateStore.upsertClips(clipStatuses) viewModelScope.launch { appReviewManager.onCreate() } } } catch (exception: Exception) { logger.e(exception) { "Error with captcha" } when (exception) { is HttpException -> { val errorResponse = exception.xGetErrorResponse() analyticsManager.trackGenerationFailed(errorResponse?.detail) } else -> { analyticsManager.trackGenerationFailed(exception.message) } } } } currentState } is SongActionBottomSheetScreenEvent.OnReportInappropriateTapped -> { genService.updateFlagState( genId = event.songId.value, flagSpec = FlagSpec( flagged = event.flagged, flaggedReason = event.flaggedReason, ), ).catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is SongActionBottomSheetScreenEvent.DeleteSong -> { genService.trashGen( trashSpec = TrashSpec( clipIds = listOf(event.song.id.value), trash = !event.undoDelete, ), ).onEach { response -> sendEvent( SongActionBottomSheetScreenEvent.Internal.HandleDeleteResponse( song = event.song, response = response, ), ) }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is SongActionBottomSheetScreenEvent.OnReportBadQualityTapped -> { genService.updateFeedbackState( genId = event.song.id.value, feedbackSpec = FeedbackSpec(feedbackReason = event.reason), ).catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is SongActionBottomSheetScreenEvent.OnDownloadTapped -> { viewModelScope.launch { try { val success = withContext(Dispatchers.IO) { downloadsRepo.downloadSong( songUrl = event.songUrl, songTitle = event.songTitle, uri = event.uri, ) } emitEffect( SongActionBottomSheetScreenEffect.OnSongDownloadComplete( success = success, songTitle = event.songTitle, ), ) } catch (e: Exception) { logger.e(e) emitEffect( SongActionBottomSheetScreenEffect.OnSongDownloadComplete( success = false, songTitle = event.songTitle, ), ) } finally { sendEvent( SongActionBottomSheetScreenEvent.Internal.SongDownloadCompleted, ) } currentState.currentSongId?.let { clipsMetricsRepository.incrementActionCount( clipId = it, action = ClipAction.DOWNLOAD_AUDIO, ) } appReviewManager.onShare() } currentState.copy( downloadState = DownloadManager.STATUS_PENDING, ) } is SongActionBottomSheetScreenEvent.OnAddToPlaylistTapped -> { currentState.copy( showAddToPlaylistBottomSheet = true, ) } is SongActionBottomSheetScreenEvent.OnRemoveFromPlaylistTapped -> { currentState } is SongActionBottomSheetScreenEvent.OnEditSongTapped -> { // no-op currentState } is SongActionBottomSheetScreenEvent.OnShareTapped -> { currentState } is SongActionBottomSheetScreenEvent.OnSongPublishTapped -> { currentState } is SongActionBottomSheetScreenEvent.OnSongUnpublishTapped -> { viewModelScope.launch { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.song_actions_tray_unpublish_dialog_title), body = UiString.Resource(R.string.song_actions_tray_unpublish_dialog_message), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.cancel), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), DialogSpec.Button( label = UiString.Resource( R.string.song_actions_tray_unpublish_dialog_button_confirm, ), onClick = { dismissDialog -> currentState.currentSongId?.let { songId -> sendEvent( SongActionBottomSheetScreenEvent.Internal.OnSongUnpublishConfirmed( songId = songId, ), ) } dismissDialog() }, type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) } currentState } is SongActionBottomSheetScreenEvent.Internal.OnSongUnpublishConfirmed -> { genService.setVisibility( genId = event.songId.value, clipVisibilitySpec = ClipVisibilitySpec(false), ).onEach { response -> sendEvent( SongActionBottomSheetScreenEvent.Internal.HandleSetVisibilityResponse(response), ) emitAnalyticEffect( analyticEffect = if (response.isSuccessful) { SongActionBottomSheetScreenAnalyticEffect.UnpublishSuccess( songId = event.songId, ) } else { SongActionBottomSheetScreenAnalyticEffect.UnpublishFailure( songId = event.songId, ) }, ) }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is SongActionBottomSheetScreenEvent.Internal.HandleSetVisibilityResponse -> { if (!event.response.isSuccessful) { // toast } else { val body: ClipVisibilityResponseEntity? = event.response.body() body?.isPublic?.let { isPublic -> body.id?.let { id -> viewModelScope.launch { mediaMetadataManager.broadcastMediaVisibility( MediaVisibility( clipId = Id(id), isPublic = isPublic, ), ) } } } } currentState } is SongActionBottomSheetScreenEvent.Internal.HandleDeleteResponse -> { if (event.response.isSuccessful) { event.response.body()?.let { responseBody -> if (responseBody.isTrashed == true) { viewModelScope.launch { emitEffect( SongActionBottomSheetScreenEffect.OnSongDeleted( deletedSong = event.song, ), ) } } else { viewModelScope.launch { emitEffect( SongActionBottomSheetScreenEffect.OnSongUndoDeleted( undoDeletedSong = event.song, ), ) } } } } currentState } is SongActionBottomSheetScreenEvent.Error.GenericError -> { logger.e(event.throwable) currentState } SongActionBottomSheetScreenEvent.Error.UserLoadError -> { currentState } is SongActionBottomSheetScreenEvent.Internal.OnSessionConfigUserLoaded -> { currentState.copy( currentViewer = event.user, ) } is SongActionBottomSheetScreenEvent.Internal.OnBillingStateLoaded -> { val billingState = event.billingInfo val hasCommercialRights = billingState?.xHasFeatureAccess(UsagePlanFeatureNames.COMMERCIAL_RIGHTS) == true val remasterModel = billingState.xGetRemasterModel() val hasCreditsLeft = (billingState?.totalCreditsLeft ?: 0) > 0 currentState.copy( isCommercialRightsEnabled = hasCommercialRights, remasterModel = remasterModel, hasCreditsLeft = hasCreditsLeft, ) } is SongActionBottomSheetScreenEvent.OptOutVideoCoverHookToggled -> { // todo: double check this in PR review currentState.currentSongId?.let { songId -> viewModelScope.launch { genService.setMetadata( songId.value, clipMetadataSpec = ClipMetadataSpec( optOutVideoCoverHook = event.optedOut, ), ).onLeft { error -> logger.e(error.toThrowable()) currentState.copy( isVideoCoverInHooksEnabled = event.optedOut, // todo: fix this ) } } } currentState.copy(isVideoCoverInHooksEnabled = !event.optedOut) } is SongActionBottomSheetScreenEvent.ToggleRemixTapped -> { currentState.currentSongId?.let { songId -> viewModelScope.launch { updateClipRemixabilityUseCase( clipId = songId, requestedAllowStatus = event.allowRemix, ) } } currentState } is SongActionBottomSheetScreenEvent.SetVideoCoverInHooksToggle -> { currentState.copy( isVideoCoverInHooksEnabled = event.enabled, ) } is SongActionBottomSheetScreenEvent.ToggleDislikeSong -> { viewModelScope.launch { updateClipReactionUseCase( clipId = event.songId, currentReactionType = event.currentReaction, requestedReactionType = ReactionType.DISLIKE, recommendationMetadata = event.hook?.toRecommendationMetadata(), ) } currentState } is SongActionBottomSheetScreenEvent.Internal.StartShareLink -> { viewModelScope.launch { val shareLink = shareLinkRepository.getSongShareLink( contentId = event.songId, platform = event.platform.backendValue, ) .getOrElse { error -> logger.e(error.toThrowable()) return@launch } shareLink?.let { link: Url -> emitEffect( SongActionBottomSheetScreenEffect.ShareLink( songId = event.songId, sharePlatform = event.platform, link = link, ), ) } } currentState } is SongActionBottomSheetScreenEvent.Internal.StartShareVideo -> { viewModelScope.launch { emitEffect( SongActionBottomSheetScreenEffect.ShareVideo( songId = event.songId, sharePlatform = event.platform, ), ) } currentState } is SongActionBottomSheetScreenEvent.StartShare -> { when (event.platform) { is SharePlatformConstants.Link -> { sendEvent( SongActionBottomSheetScreenEvent.Internal.StartShareLink( songId = event.songId, platform = event.platform, ), ) currentState } is SharePlatformConstants.Video -> { sendEvent( SongActionBottomSheetScreenEvent.Internal.StartShareVideo( songId = event.songId, platform = event.platform, ), ) currentState } } } SongActionBottomSheetScreenEvent.Internal.SongDownloadCompleted -> { currentState.copy( downloadState = null, ) } is SongActionBottomSheetScreenEvent.Internal.InitializeSong -> { currentState.copy( song = event.song, ) } } } fun onSongShared( platform: String?, ) { viewModelScope.launch { state.value.currentSongId?.let { clipsMetricsRepository.incrementActionCount( clipId = it, action = ClipAction.SHARE, sharePlatform = platform, ) } appReviewManager.onShare() } } }