package com.suno.android.ui.screens.playlist import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute import arrow.core.getOrElse import arrow.retrofit.adapter.either.networkhandling.CallError 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.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_core_utils.model.UserHandle import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull 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.ClipVisibilitySpec import com.suno.android.common_networking.remote.entities.IncrementPlaylistPlayCountSpec import com.suno.android.common_networking.remote.entities.PlaylistRemoveClipsByIdMetadata import com.suno.android.common_networking.remote.entities.PlaylistSchema import com.suno.android.common_networking.remote.entities.PlaylistTrashSpec import com.suno.android.common_networking.remote.entities.PlaylistUpdateClipsSpec import com.suno.android.common_networking.remote.entities.PlaylistVisibilitySpec import com.suno.android.common_networking.remote.entities.ResultInner import com.suno.android.common_networking.remote.entities.SearchQuerySchema import com.suno.android.common_networking.remote.entities.SearchRankingEnum import com.suno.android.common_networking.remote.entities.SearchRequest import com.suno.android.common_networking.remote.entities.SearchResult import com.suno.android.common_networking.remote.entities.SearchTypeEnum 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.playlist.PlaylistService import com.suno.android.common_networking.remote.playlist.PostUpdatePlaylistTitle import com.suno.android.common_networking.remote.playlist_reaction.PlaylistReactionService import com.suno.android.common_networking.remote.search.SearchService 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.media.MediaMetadataManager import com.suno.android.media.MediaVisibility import com.suno.android.review.AppReviewManager import com.suno.android.ui.screens.navigation.NavDestination import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject const val LIKED_PLAYLIST_ID = "liked" @HiltViewModel class PlaylistScreenVM @Inject constructor( processorFactory: MviProcessorFactory, savedStateHandle: SavedStateHandle, private val playlistService: PlaylistService, private val playlistReactionService: PlaylistReactionService, private val appReviewManager: AppReviewManager, private val genService: GenService, private val mediaMetadataManager: MediaMetadataManager, private val mediaManager: MediaManager, private val topLevelErrorManager: TopLevelErrorManager, private val billingRepo: SunoBillingRepo, private val listeningSourceCache: ListeningSourceCache, searchService: SearchService, userSessionRepository: UserSessionRepository, featureManager: FeatureManager, ) : MviViewModel( processorFactory = processorFactory, initialState = PlaylistScreenState( isShowShareVideoGateEnabled = featureManager.hasFeature(Feature.ShowShareVideo), ), ) { private val playlistId: String? = savedStateHandle.toRoute().playlistId private val searchTag: String? = savedStateHandle.toRoute().searchId private val styleTag: String? = savedStateHandle.toRoute().styleId private val searchKey: String? = savedStateHandle.toRoute().searchKey private fun loadPlaylist( playlistId: String, onSuccess: (PlaylistSchema) -> Unit = {}, onError: (CallError) -> Unit = {}, ) { viewModelScope.launch { val playlist = playlistService.getPlaylistById(playlistId).getOrElse { error -> logger.e(error.toThrowable()) onError(error) return@launch } val playlistClips = playlist.playlistClips val songListData = playlistClips.mapNotNull { it.clip.xAsSongListDataOrNull() } updateState { oldState -> val imageUrl = playlist.imageUrl ?: playlistClips.firstOrNull()?.clip?.imageLargeUrl oldState.copy( playlist = playlist, loading = false, imageUrl = imageUrl?.let(::Url), songListData = songListData, playlistTitle = playlist.name, isPublic = playlist.isPublic, userDisplayName = playlist.userDisplayName, userAvatarUrl = playlist.userAvatarImageUrl?.let(::Url), userHandle = playlist.userHandle?.let(::UserHandle), ) } onSuccess(playlist) } } init { if (playlistId != null) { loadPlaylist(playlistId = playlistId) } else if (searchTag != null) { searchService.postSearch( SearchRequest( searchQueries = listOf( SearchQuerySchema( searchType = SearchTypeEnum.TagSong, name = SearchTypeEnum.TagSong.value, term = searchTag, fromIndex = 0, rankBy = SearchRankingEnum.most_relevant, ), ), ), ).onEach { response -> logger.println { response } val searchResult: SearchResult? = response.body() if (searchResult != null) { val searchResults = searchResult.result["tag_song"]?.result?.mapNotNull { clip: ResultInner -> clip.xAsSongListDataOrNull() } updateState { oldState -> oldState.copy( loading = false, imageUrl = searchResults?.first()?.displayImageUrl, songListData = searchResults.orEmpty(), playlistTitle = searchTag, isPublic = true, userDisplayName = "", ) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } mediaMetadataManager.mediaVisibilityFlow() .onEach { visibility -> updateState { oldState -> oldState.songListData.find { it.id == visibility.clipId }?.let { song -> if (song.isPublic != visibility.isPublic) { val updatedSong = song.copy(isPublic = visibility.isPublic) val updatedSongs = oldState.songListData.map { songData -> if (songData.id == song.id) updatedSong else songData } return@updateState oldState.copy(songListData = updatedSongs) } } oldState } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) mediaMetadataManager.mediaRemixabilityFlow() .onEach { canRemix -> updateState { oldState -> oldState.songListData.find { it.id == canRemix.clipId }?.let { song -> if (song.canRemix != canRemix.canRemix) { val updatedSong = song.copy(canRemix = canRemix.canRemix) val updatedSongs = oldState.songListData.map { songData -> if (songData.id == song.id) updatedSong else songData } return@updateState oldState.copy(songListData = updatedSongs) } } oldState } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> updateState { oldState -> oldState.copy( viewerId = sessionConfiguration.user?.id, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) billingRepo.billingStateFlow().onEach { billingInfo -> updateState { oldState -> oldState.copy( isFreeUser = billingInfo?.isActive != true, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } override suspend fun reduceEvent( currentState: PlaylistScreenState, event: PlaylistScreenEvent, emitEffect: suspend (PlaylistScreenEffect) -> Unit, ): PlaylistScreenState = when (event) { is PlaylistScreenEvent.OnSongRenamed -> { val localClipData = event.renamedSong.asLocalClipData() mediaManager.updateClip( localClipData, localClipData.copy(nowPlayingTitle = event.renamedSong.title), ) val updatedSongs = currentState.songListData.map { if (it.id == event.renamedSong.id) event.renamedSong else it } currentState.copy(songListData = updatedSongs) } is PlaylistScreenEvent.OnSongSelected -> { event val songList = currentState.songListData.map { it.asLocalClipData() } val chosenSong = songList.first { it.clipId == event.clipId } // Store listening source for analytics (Priority: Search context > StyleTag > Playlist) val listeningSource = when { searchKey != null -> ListeningSource.Search(searchKey = searchKey) styleTag != null -> ListeningSource.StyleTag(tag = styleTag) playlistId != null -> ListeningSource.PlaylistSource(playlistId = Id(playlistId)) else -> null } listeningSource?.let { listeningSourceCache.put( clipId = chosenSong.clipId.map(), listeningSource = listeningSource, ) } mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) currentState } is PlaylistScreenEvent.OnSongOverflowOpened -> { currentState.copy(songIdToOperate = event.songId) } is PlaylistScreenEvent.OnSongShared -> { // TODO: tomdroid log share count viewModelScope.launch { appReviewManager.onShare() } currentState } is PlaylistScreenEvent.OnSongDeleted -> { mediaManager.removeClipById(event.deletedSong.id) val songToDelete = currentState.songListData.first { it.id == event.deletedSong.id } val updatedSongs = currentState.songListData - songToDelete currentState.copy( deletedSong = songToDelete, songListData = updatedSongs, ) } is PlaylistScreenEvent.OnChangeClipVisiblity -> { currentState.songIdToOperate?.let { songId -> currentState.songListData.find { it.id == songId }?.let { song -> genService.setVisibility( genId = song.id.value, clipVisibilitySpec = ClipVisibilitySpec(event.setPublic), ).onEach { response -> if (!response.isSuccessful) { // resetSnackbar() // set toast feedback } else { val body: ClipVisibilityResponseEntity? = response.body() body?.isPublic?.let { isPublic -> body.id?.let { id -> viewModelScope.launch { mediaMetadataManager.broadcastMediaVisibility( MediaVisibility( clipId = Id(id), isPublic = isPublic, ), ) } } } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } } currentState } is PlaylistScreenEvent.OnChangePlaylistTitle -> { currentState.playlist?.let { thePlaylist -> viewModelScope.launch { val playlistTitle = PostUpdatePlaylistTitle( playlistId = thePlaylist.id, name = event.title, description = thePlaylist.description.orEmpty(), ) playlistService.setPlaylistTitle(playlistTitle).getOrElse { error -> logger.e(error.toThrowable()) return@launch } updateState { it.copy(playlistTitle = event.title) } } } currentState } is PlaylistScreenEvent.OnPlaylistDeleted -> { viewModelScope.launch { playlistId?.let { id -> val spec = PlaylistTrashSpec(id, undoTrash = false) playlistService.trashPlaylist(spec).getOrElse { error -> logger.e(error.toThrowable()) return@launch } event.onDelete() } } currentState } is PlaylistScreenEvent.OnTogglePlaylistVisibility -> { viewModelScope.launch { try { val isPublic = currentState.isPublic == true val newPublicity = !isPublic val response = playlistReactionService.setVisibility( playlistId = playlistId.orEmpty(), playlistVisibilitySpec = PlaylistVisibilitySpec( isPublic = newPublicity, ), ) if (response.isSuccessful) { updateState { it.copy(isPublic = newPublicity) } } else { // todo: handle error } } catch (exception: Exception) { logger.e(exception) } } currentState } is PlaylistScreenEvent.OnRemoveSongFromPlaylist -> { viewModelScope.launch { val spec = PlaylistUpdateClipsSpec( playlistId = playlistId.orEmpty(), updateType = "remove_by_id", metadata = PlaylistRemoveClipsByIdMetadata(listOf(event.songListData.id.value)), ) playlistService.updatePlaylistClips(spec).getOrElse { error -> logger.e(error.toThrowable()) return@launch } updateState { oldState -> val songListData = oldState.songListData.filter { song -> song.id != event.songListData.id } oldState.copy(songListData = songListData) } } currentState } is PlaylistScreenEvent.OnIncrementPlaylistPlaycount -> { playlistReactionService.playlistReactionApiIncrementPlayCount( playlistId = playlistId.orEmpty(), incrementPlaylistPlayCountSpec = IncrementPlaylistPlayCountSpec( clipId = event.clipId.value, ), ).catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is PlaylistScreenEvent.OnRefreshPlaylist -> { if (!playlistId.isNullOrEmpty()) { loadPlaylist( playlistId, { updateState { it.copy(isRefreshing = false) } }, { updateState { it.copy(isRefreshing = false) } }, ) currentState.copy(isRefreshing = true) } else { currentState } } is PlaylistScreenEvent.OnShowDeleteDialog -> { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( title = UiString.Resource(R.string.delete_playlist_title), body = UiString.Resource(R.string.unable_get_playlist_back), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(R.string.cancel), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), DialogSpec.Button( label = UiString.Resource(R.string.delete), onClick = { dismissDialog -> sendEvent( PlaylistScreenEvent.OnPlaylistDeleted( onDelete = { viewModelScope.launch { emitEffect(PlaylistScreenEffect.DeletePlaylist) } }, ), ) dismissDialog() }, type = DialogSpec.Button.DialogButtonType.PRIMARY, ), ), ), ) currentState } } fun triggerSnackbar( message: String, ) { emitEffect(PlaylistScreenEffect.ShowSnackbar(message = message)) } fun isPlayerVisible(): Boolean = mediaManager.hasPlayedAnyClip() }