package com.suno.android.ui.screens.root.logged_in import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.common_analytics.listening_source.ListeningSource import com.suno.android.common_analytics.listening_source.ListeningSourceCache import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.environment.ThemeMode import com.suno.android.common_core_utils.environment.UserPrefsDataStoreManager 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.generation.SongGenerationStateStore import com.suno.android.common_data.managers.HookDownloadManager import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_networking.remote.session.User import com.suno.android.common_res.R import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.media.MediaManager import com.suno.android.review.AppReviewManager import com.suno.android.utils.resolvers.ScreenVisitResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class LoggedInNavGraphVM @Inject constructor( loggerFactory: SunoLogger.Factory, private val appReviewManager: AppReviewManager, private val analyticsManager: AnalyticsManager, private val listeningSourceCache: ListeningSourceCache, private val screenVisitResolver: ScreenVisitResolver, songGenerationStateStore: SongGenerationStateStore, userPrefsDataStoreManager: UserPrefsDataStoreManager, private val mediaManager: MediaManager, private val clipsRepository: ClipsRepository, private val userSessionRepository: UserSessionRepository, private val featureManager: FeatureManager, hookDownloadManager: HookDownloadManager, private val topLevelErrorManager: TopLevelErrorManager, ) : ViewModel() { private val logger = loggerFactory.create(this@LoggedInNavGraphVM) private val _viewStateFlow = MutableStateFlow( UiState( isCreateBottomSheetGateEnabled = featureManager.hasFeature(Feature.UseCreateBottomSheet), isHookDownloadFeatureEnabled = featureManager.hasFeature(Feature.HooksDownload), isHookDownloadSelfFeatureEnabled = featureManager.hasFeature(Feature.HooksDownloadSelf), isOrpheusChatEnabled = featureManager.hasFeature(Feature.OrpheusChat), ), ) private val _effectFlow = MutableSharedFlow() init { appReviewManager.appReviewEventFlow() .onEach { _viewStateFlow.update { oldState -> oldState.copy( appReviewDialogActive = true, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) songGenerationStateStore.songGenerationStateFlow().onEach { songGenerationState -> _viewStateFlow.update { oldState -> val wasGenerating = oldState.isSongGenerating val isGenerating = songGenerationState.generatingClipIds.isNotEmpty() val newIds = songGenerationState.readyClipIds.filter { clipIds -> clipIds !in oldState.newlyGeneratedSongData.map { it.clipId } } viewModelScope.launch { val clips = newIds.map { id -> async { clipsRepository.getClipById(id) .getOrElse { error -> logger.e(error) { "Error loading clip by id" } null } } }.awaitAll() _viewStateFlow.update { it.copy( newlyGeneratedSongData = if (!wasGenerating && isGenerating) { emptyList() } else { oldState.newlyGeneratedSongData + clips.filterNotNull() }, ) } } oldState.copy( isSongGenerating = isGenerating, isSongGenerationBannerDismissed = if (newIds.isNotEmpty()) { false } else { oldState.isSongGenerationBannerDismissed }, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) userPrefsDataStoreManager.getThemeMode() .onEach { themeMode -> _viewStateFlow.update { oldState -> oldState.copy( themeMode = themeMode, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> _viewStateFlow.update { oldState -> oldState.copy(currentUser = sessionConfiguration.user) } }.launchIn(viewModelScope) hookDownloadManager.downloadStateFlow().onEach { downloadState -> _viewStateFlow.update { oldState -> if (!oldState.isHookDownloadFeatureEnabled) { if (!oldState.isHookDownloadSelfFeatureEnabled) return@onEach val download = downloadState as? HookDownloadManager.HookDownloadState.Downloading ?: return@onEach val isOwnHook = download.hookCreatorHandle.toString() .equals(oldState.currentUser?.handle, ignoreCase = true) if (!isOwnHook) return@onEach } val newHookDownloadState = when (downloadState) { is HookDownloadManager.HookDownloadState.Idle -> UiState.HookDownloadState.Idle is HookDownloadManager.HookDownloadState.Downloading -> UiState.HookDownloadState.Downloading(downloadState.hookId) is HookDownloadManager.HookDownloadState.Complete -> UiState.HookDownloadState.Complete( hookId = downloadState.hookId, hookPreviewUrl = downloadState.hookPreviewUrl, videoUri = downloadState.localVideoUri, ) is HookDownloadManager.HookDownloadState.Error -> { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( body = UiString.Resource(R.string.hook_download_failed), ), ) logger.w { "Hook download error: ${downloadState.error}" } UiState.HookDownloadState.Idle } } oldState.copy( hookDownloadState = newHookDownloadState, isHookDownloadBannerDismissed = downloadState is HookDownloadManager.HookDownloadState.Idle, ) } }.catch { exception -> logger.e(exception) { "Error observing hook download state" } }.launchIn(viewModelScope) // user needs to be evaluated under USE_CREATE_BOTTOM_SHEET flag (which should be 100% pass) to be included in experiment // true value is determined by experiment, not flag _viewStateFlow.update { oldState -> oldState.copy( isCreateBottomSheetGateEnabled = featureManager.hasFeature(Feature.Workspaces), ) } } fun viewStateFlow() = _viewStateFlow.asStateFlow() fun effectFlow() = _effectFlow.asSharedFlow() fun handleOnboardingComplete() { viewModelScope.launch { _effectFlow.emit( LoggedInEffects.OnRouteToExplore, ) } } fun dismissAppReviewDialog() { _viewStateFlow.update { oldState -> oldState.copy( appReviewDialogActive = false, ) } viewModelScope.launch { appReviewManager.onNotify() } } fun trackScreen( screenName: String?, ) { screenVisitResolver.resolve(screenName)?.value?.let { analyticsManager.trackScreenVisit(it) } } fun handleNewSongDialogClicked() { val clipIds = viewStateFlow().value.newlyGeneratedSongData.map { it.clipId }.reversed() if (clipIds.isEmpty()) return viewModelScope.launch { val deferredClips = clipIds.map { clipId -> async(Dispatchers.IO) { clipsRepository.getClipById(clipId) .getOrElse { error -> logger.e(error) { "Error loading clip by id" } null } } } val loadedClips = deferredClips.awaitAll().filterNotNull() val chosenClip = loadedClips.firstOrNull() if (loadedClips.isNotEmpty() && chosenClip != null) { /** Store listening source as TopToast for immediate song to allow retrieval for analytics */ listeningSourceCache.put( clipId = loadedClips.first().clipId.map(), listeningSource = ListeningSource.TopToast, ) mediaManager.setCurrentlyPlayingPlaylist( songList = loadedClips, chosenSong = chosenClip, ) } _viewStateFlow.update { oldState -> oldState.copy(newlyGeneratedSongData = emptyList()) } } } fun dismissSongGenerationBanner() { _viewStateFlow.update { it.copy( isSongGenerationBannerDismissed = true, ) } } fun resetSongGenerationBannerState() { _viewStateFlow.update { it.copy( isSongGenerating = false, newlyGeneratedSongData = emptyList(), ) } } fun dismissHookDownloadBanner() { _viewStateFlow.update { it.copy(isHookDownloadBannerDismissed = true) } } fun openDownloadedHookInGallery( videoUri: Uri, ) { viewModelScope.launch { _effectFlow.emit(LoggedInEffects.OpenVideoInGallery(videoUri)) } } fun resetHookDownloadBannerState() { _viewStateFlow.update { it.copy(hookDownloadState = UiState.HookDownloadState.Idle) } } fun logError( throwable: Throwable, ) { logger.e(throwable) } data class UiState( val alertDialogActive: Boolean = false, val alertDialogMessageResId: Int? = null, val appReviewDialogActive: Boolean = false, val artistName: String? = null, val title: String? = null, val albumUrl: String? = null, val onConfigLoaded: Boolean = false, val themeMode: ThemeMode = ThemeMode.SYSTEM, val newlyGeneratedSongData: List = emptyList(), val isCreateBottomSheetGateEnabled: Boolean = false, val isSongGenerating: Boolean = false, val isSongGenerationBannerDismissed: Boolean = false, val isHookDownloadFeatureEnabled: Boolean = false, val isHookDownloadSelfFeatureEnabled: Boolean = false, val hookDownloadState: HookDownloadState = HookDownloadState.Idle, val isHookDownloadBannerDismissed: Boolean = false, val isOrpheusChatEnabled: Boolean = false, val currentUser: User? = null, ) { val showGenerationBanner: Boolean get() = !isOrpheusChatEnabled && (isSongGenerating || newlyGeneratedSongData.isNotEmpty()) && !isSongGenerationBannerDismissed sealed interface HookDownloadState { data object Idle : HookDownloadState data class Downloading( val hookId: Id, ) : HookDownloadState data class Complete( val hookId: Id, val hookPreviewUrl: Url?, val videoUri: Uri?, ) : HookDownloadState } } sealed interface LoggedInEffects { data object OnRouteToOnboarding : LoggedInEffects data object OnRouteToD0Create : LoggedInEffects data object OnRouteToExplore : LoggedInEffects data class OpenVideoInGallery( val videoUri: Uri, ) : LoggedInEffects } }