package com.suno.android.ui.screens.home.profile import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute 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_core_utils.Id import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.clips.xAsLocalClipData import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_data.user.UserProfile 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.remote.entities.RemoteFollowArtistProfileBody import com.suno.android.common_networking.remote.profiles.ProfilesService import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.media.MediaManager import com.suno.android.media.MediaMetadataManager import com.suno.android.media.MediaVisibility import com.suno.android.ui.screens.home.profile.user_hooks.UserHooksController import com.suno.android.ui.screens.home.profile.user_hooks.UserHooksState import com.suno.android.ui.screens.navigation.NavDestination import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ProfileScreenVM @Inject constructor( processorFactory: MviProcessorFactory, savedStateHandle: SavedStateHandle, private val profilesService: ProfilesService, private val mediaManager: MediaManager, private val userSessionRepository: UserSessionRepository, private val mediaMetadataManager: MediaMetadataManager, private val featureManager: FeatureManager, private val billingRepo: SunoBillingRepo, private val listeningSourceCache: ListeningSourceCache, userHooksControllerFactory: UserHooksController.Factory, ) : MviViewModel( processorFactory = processorFactory, initialState = ProfileScreenState( isSelf = savedStateHandle.toRoute().isSelf, userHandle = UserHandle(savedStateHandle.toRoute().userHandle), isShowShareVideoGateEnabled = featureManager.hasFeature(Feature.ShowShareVideo), userHooksState = UserHooksState.Default, ), ) { private val toRoute: NavDestination.Profile.ProfileScreenDestination = savedStateHandle.toRoute() private val isSelf = toRoute.isSelf private val userHandle = UserHandle(toRoute.userHandle) private val searchKey: String? = toRoute.searchKey private val userHooksController = userHooksControllerFactory.create( scope = viewModelScope, userHandle = userHandle.handle, isSelf = isSelf, ).also { controller -> controller.state.onEach { userHooksState: UserHooksState -> sendEvent( ProfileScreenEvent.Internal.UserHooksLoaded( userHooksState = userHooksState, ), ) }.launchIn(viewModelScope) } init { if (isSelf) { upstreamFlows .bind( source = userSessionRepository.sessionConfigurationStateFlow() .mapNotNull { config -> config.user } .distinctUntilChanged() .mapNotNull { user -> user.handle?.let(::UserHandle) }, map = { loadedHandle -> ProfileScreenEvent.Internal.LoadProfileRequested(loadedHandle) }, onErrorEvent = { ProfileScreenEvent.Error.UserLoadError }, ) upstreamFlows .bind( source = mediaMetadataManager.mediaVisibilityFlow(), map = { ProfileScreenEvent.Internal.MediaVisibilityChanged( visibility = MediaVisibility( it.clipId, isPublic = it.isPublic, ), ) }, onErrorEvent = ProfileScreenEvent.Error::GenericError, ) upstreamFlows.bind( source = mediaMetadataManager.mediaRemixabilityFlow(), map = { ProfileScreenEvent.Internal.MediaRemixabilityChanged( clipId = it.clipId, canRemix = it.canRemix, ) }, onErrorEvent = ProfileScreenEvent.Error::GenericError, ) } else { sendEvent(ProfileScreenEvent.Internal.LoadProfileRequested(userHandle)) } upstreamFlows .bind( source = mediaMetadataManager.mediaReactionFlow(), map = { ProfileScreenEvent.Internal.MediaReactionChanged( clipId = it.clipId, reaction = it.reaction, ) }, onErrorEvent = ProfileScreenEvent.Error::GenericError, ) billingRepo.billingStateFlow().onEach { billingInfo -> updateState { oldState -> oldState.copy( isFreeUser = billingInfo?.isActive != true, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } private fun ProfileScreenState.handleSongRenamed( renamedSong: SongListData, ): ProfileScreenState { val updatedSongs = this.clips.map { if (it.id == renamedSong.id) renamedSong else it } val localClipData = renamedSong.xAsLocalClipData() mediaManager.updateClip( localClipData, localClipData.copy(nowPlayingTitle = renamedSong.title), ) return this.copy(clips = updatedSongs) } private fun ProfileScreenState.handleSongDeleted( deletedSong: SongListData, ): ProfileScreenState { val songToDelete = this.clips.first { it.id == deletedSong.id } val updatedSongs = this.clips - songToDelete mediaManager.removeClipById(deletedSong.id) return this.copy( deletedSong = songToDelete, clips = updatedSongs, ) } private fun fetchProfileDataForHandle( userHandle: UserHandle, ) { viewModelScope.launch { val responseBody = profilesService.getArtistProfile( handle = userHandle.handle, playlistsSortBy = "upvote_count", clipsSortBy = "upvote_count", ).getOrElse { error -> logger.e(error.toThrowable()) sendEvent(ProfileScreenEvent.Error.UserLoadError) return@launch } val avatarUrl = responseBody.avatarImageUrl?.let(::Url) val followerCount = responseBody.stats?.followersCount val followingCount = responseBody.stats?.followingCount val displayName = responseBody.displayName.orEmpty() val userId = responseBody.userId.orEmpty() val clips = responseBody.clips.orEmpty().mapNotNull { it.xAsSongListDataOrNull() } val playlists = responseBody.playlists.orEmpty() val isFollowing = responseBody.isFollowing == true sendEvent( ProfileScreenEvent.Internal.LoadProfileSucceeded( userHandle = userHandle, avatarImageUrl = avatarUrl, followerCount = followerCount, followingCount = followingCount, isFollowing = isFollowing, displayName = displayName, userId = Id(userId), clips = clips, playlists = playlists, ), ) } } private fun ProfileScreenState.onSongClicked( songListData: SongListData, ): ProfileScreenState { val songList = this.clips.map { it.xAsLocalClipData() } val chosenSong = songList.first { it.mediaUrl == songListData.mediaUrl } this.userId?.let { cacheListeningSource(userId = this.userId, clipId = chosenSong.clipId.map()) } mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) return this } private fun onEditProfileClicked() { viewModelScope.launch { emitEffect(ProfileScreenEffect.OnEditProfileClicked) } } private fun onFollowClicked() { viewModelScope.launch { val body = RemoteFollowArtistProfileBody( handle = userHandle.handle, unfollow = state.value.isFollowing, ) profilesService.followArtistProfile(body).getOrElse { error -> logger.e(error.toThrowable()) return@launch } sendEvent( ProfileScreenEvent.Internal.FollowStateToggled, ) } } private fun onPlayClicked() { val songList = state.value.clips.map { it.xAsLocalClipData() } val chosenSong = songList.first() state.value.userId?.let { userId -> cacheListeningSource(userId = userId, clipId = chosenSong.clipId.map()) } mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) } fun isPlayerVisible(): Boolean = mediaManager.hasPlayedAnyClip() private fun ProfileScreenState.onSongOverflowOpened( songId: Id, ): ProfileScreenState = this.copy(songIdToOperate = songId) private fun ProfileScreenState.resetSnackBar(): ProfileScreenState = this.copy(snackBarMessage = "") private fun showSnackBar( message: String, ) { viewModelScope.launch { emitEffect(ProfileScreenEffect.ShowSnackbar(message)) } } fun getSongIdForOperate(): Id? = state.value.songIdToOperate /** * Store listening source for analytics * Priority: Search (if navigated from search) > Profile */ private fun cacheListeningSource( userId: Id, clipId: Id, ) { listeningSourceCache.put( clipId = clipId, listeningSource = when { searchKey != null -> ListeningSource.Search(searchKey = searchKey) else -> ListeningSource.Profile(userId = userId) }, ) } override suspend fun reduceEvent( currentState: ProfileScreenState, event: ProfileScreenEvent, emitEffect: suspend (ProfileScreenEffect) -> Unit, ): ProfileScreenState = when (event) { is ProfileScreenEvent.OnSongClicked -> { currentState.onSongClicked(event.song) } is ProfileScreenEvent.OnSongOverflowOpened -> { currentState.onSongOverflowOpened(event.songId) } is ProfileScreenEvent.OnSongDeleted -> { currentState.handleSongDeleted(event.deletedSong) } is ProfileScreenEvent.OnSongRenamed -> { currentState.handleSongRenamed(event.renamedSong) } is ProfileScreenEvent.OnEditProfileClicked -> { onEditProfileClicked() currentState } is ProfileScreenEvent.OnFollowClicked -> { onFollowClicked() currentState } is ProfileScreenEvent.OnPlayClicked -> { onPlayClicked() currentState } is ProfileScreenEvent.OnResetSnackBar -> { currentState.resetSnackBar() } is ProfileScreenEvent.OnShowSnackbar -> { showSnackBar(event.message) currentState } ProfileScreenEvent.OnFollowersClicked -> { emitEffect(ProfileScreenEffect.NavigateToFollowers) currentState } ProfileScreenEvent.OnFollowingClicked -> { emitEffect(ProfileScreenEffect.NavigateToFollowing) currentState } is ProfileScreenEvent.OnHookCardTapped -> { emitEffect(ProfileScreenEffect.NavigateToHooks(event.hookIndex)) currentState } ProfileScreenEvent.Internal.UserLoaded -> { currentState } is ProfileScreenEvent.Internal.LoadProfileRequested -> { fetchProfileDataForHandle( userHandle = event.userHandle, ) currentState.copy( isLoading = true, ) } ProfileScreenEvent.Error.UserLoadError -> { currentState.copy( isLoading = false, ) } is ProfileScreenEvent.Internal.LoadProfileSucceeded -> { currentState.copy( isLoading = false, userHandle = event.userHandle, avatarImageUrl = event.avatarImageUrl, followerCount = event.followerCount, followingCount = event.followingCount, isFollowing = event.isFollowing, userName = event.displayName, userId = event.userId, clips = event.clips, playlists = event.playlists, ) } is ProfileScreenEvent.Error.GenericError -> { logger.e(event.throwable) currentState.copy(isLoading = false) } is ProfileScreenEvent.Internal.MediaRemixabilityChanged -> { currentState.clips.find { it.id == event.clipId }?.let { song -> if (song.canRemix != event.canRemix) { val updatedSong = song.copy(canRemix = event.canRemix) val updatedSongs = currentState.clips.map { if (it.id == song.id) updatedSong else it } currentState.copy( clips = updatedSongs, ) } else { currentState } } ?: currentState } is ProfileScreenEvent.Internal.MediaVisibilityChanged -> { currentState.clips.find { it.id == event.visibility.clipId }?.let { song -> if (song.isPublic != event.visibility.isPublic) { val updatedSong = song.copy(isPublic = event.visibility.isPublic) val updatedSongs = currentState.clips.map { if (it.id == song.id) updatedSong else it } currentState.copy( clips = updatedSongs, ) } else { currentState } } ?: currentState } is ProfileScreenEvent.Internal.UserHooksLoaded -> { currentState.copy( userHooksState = event.userHooksState, ) } is ProfileScreenEvent.Internal.MediaReactionChanged -> { currentState.clips.find { it.id == event.clipId }?.let { song -> if (song.reaction != event.reaction) { val updatedSong = song.copy(reaction = event.reaction) val updatedSongs = currentState.clips.map { if (it.id == song.id) updatedSong else it } currentState.copy( clips = updatedSongs, ) } else { currentState } } ?: currentState } ProfileScreenEvent.Internal.FollowStateToggled -> { val newFollowing = !currentState.isFollowing val newFollowerCount = currentState.followerCount?.let { currentCount -> if (newFollowing) currentCount + 1 else currentCount - 1 } currentState.copy( isFollowing = newFollowing, followerCount = newFollowerCount, ) } } override fun onCleared() { super.onCleared() userHooksController.onClear() } }