package com.suno.android.ui.screens.home.library import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.common_analytics.listening_source.LibraryFilter 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.constants.ReactionType import com.suno.android.common_core_utils.extensions.upsert import com.suno.android.common_core_utils.extensions.xToDate import com.suno.android.common_core_utils.helpers.PeriodicFlowExecutor import com.suno.android.common_core_utils.model.UiString 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.mappers.clips.xAsLocalClipData import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_data.repos.GenerationRepository 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.common.RemoteClip import com.suno.android.common_networking.remote.entities.PlaylistSchema import com.suno.android.common_networking.remote.entities.TrashSpec import com.suno.android.common_networking.remote.feed.FeedService import com.suno.android.common_networking.remote.feed.GetFeedV2ResponseEntity 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.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.media.MediaMetadataManager import com.suno.android.ui.screens.home.library.LibraryScreenEffect.ShowSnackbar.Type import com.suno.android.ui.screens.playlist.LIKED_PLAYLIST_ID import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import retrofit2.Response import javax.inject.Inject @HiltViewModel class LibraryScreenVM @Inject constructor( processorFactory: MviProcessorFactory, private val feedService: FeedService, private val playlistService: PlaylistService, private val mediaManager: MediaManager, private val genService: GenService, private val userSessionRepository: UserSessionRepository, private val billingRepo: SunoBillingRepo, private val songGenerationStateStore: SongGenerationStateStore, private val mediaMetadataManager: MediaMetadataManager, private val featureManager: FeatureManager, private val listeningSourceCache: ListeningSourceCache, private val generationRepository: GenerationRepository, ) : MviViewModel( processorFactory = processorFactory, initialState = LibraryScreenState( isLoading = true, isShowShareVideoGateEnabled = featureManager.hasFeature(Feature.ShowShareVideo), ), ) { private val refreshFeedPollingFlowExecutor = PeriodicFlowExecutor( scope = viewModelScope, flowProvider = { feedService.getFeedV2( page = 0, isLiked = isLiked, isPublic = isPublic, hideDisliked = true, hideStudioClips = true, hideGenStems = true, ) }, ) private var _currentViewer: User? = null private var currentPage = 0 private var isLiked: Boolean? = null // for filter on my own songs private var isPublic: Boolean? = null // for filter on public songs private fun fetchNextPage() { currentPage++ fetchPage() } private fun fetchNextPageForLikedSongs() { currentPage++ fetchPageForLikedSongs() } private fun dedupeOnDisplayedSongIds( generatingClipIds: List>, songList: List, ): ImmutableList> { // dedupe possible songs shown in both library and gens val displayedSongIds = songList.asSequence() .filter { it.status.isReady } .map { it.id } val generatingSongs = generatingClipIds.filter { genClipId -> !displayedSongIds.contains(genClipId) } return generatingSongs.toImmutableList() } init { refreshFeedPollingFlowExecutor.flow().onEach { response -> handleFeedResponse(response = response) }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) refreshFeedPollingFlowExecutor.trigger() generationRepository.pollAllGeneratingSongs().onEach { result -> result.onRight { clips -> val generatingClips = mutableListOf() val failedClips = mutableListOf() clips.forEach { when (ClipStatus.fromString(it.status)) { ClipStatus.Queued, ClipStatus.Submitted -> generatingClips ClipStatus.Error -> failedClips else -> null }?.add(it) } songGenerationStateStore.removeClips(failedClips.mapTo(mutableSetOf()) { Id(it.id) }) failedClips.mapTo(mutableSetOf()) { it.clipMetadata.errorMessage?.let(UiString::Raw) ?: UiString.Resource(R.string.error) }.forEach { uiString -> emitEffect( LibraryScreenEffect.ShowSnackbar( type = LibraryScreenEffect.ShowSnackbar.Type.SongGenerationFailed( message = uiString, ), ), ) } val generatingClipIds = generatingClips.mapTo(mutableSetOf()) { Id(it.id) } updateState { it.copy( generatingSongIds = dedupeOnDisplayedSongIds( generatingClipIds = generatingClipIds.toList(), songList = it.mySongsList, ), readySongIds = songGenerationStateStore .songGenerationStateFlow() .value.readyClipIds .toImmutableList(), ) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> val user = sessionConfiguration.user _currentViewer = user updateState { oldState -> oldState.copy( userAvatarUrl = user?.avatarImageUrl.orEmpty(), ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) songGenerationStateStore.songGenerationStateFlow().onEach { songGenerationState -> updateState { oldState -> oldState.copy( generatingSongIds = songGenerationState.generatingClipIds.toImmutableList(), readySongIds = songGenerationState.readyClipIds.toImmutableList(), ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) mediaMetadataManager.mediaReactionFlow().onEach { reaction -> state.value.mySongsList.find { it.id == reaction.clipId }?.let { song -> if (song.reaction != reaction.reaction) { updateState { oldState -> val updatedSong = song.copy(reaction = reaction.reaction) val updatedSongs = oldState.mySongsList.map { if (it.id == song.id) updatedSong else it } oldState.copy(mySongsList = updatedSongs) } } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) mediaMetadataManager.mediaRemixabilityFlow().onEach { canRemix -> state.value.mySongsList.find { it.id == canRemix.clipId }?.let { song -> if (song.canRemix != canRemix.canRemix) { updateState { oldState -> val updatedSong = song.copy(canRemix = canRemix.canRemix) val updatedSongs = oldState.mySongsList.map { if (it.id == song.id) updatedSong else it } oldState.copy(mySongsList = updatedSongs) } } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) mediaMetadataManager.mediaVisibilityFlow().onEach { visibility -> state.value.mySongsList.find { it.id == visibility.clipId }?.let { song -> if (song.isPublic != visibility.isPublic) { updateState { oldState -> val updatedSong = song.copy(isPublic = visibility.isPublic) val updatedSongs = oldState.mySongsList.map { if (it.id == song.id) updatedSong else it } oldState.copy(mySongsList = updatedSongs) } } } }.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) } fun isPlayerVisible(): Boolean = mediaManager.hasPlayedAnyClip() private fun LibraryScreenState.onAllFilterSelected(): LibraryScreenState { isLiked = null isPublic = null currentPage = 0 fetchPage() return this.copy( isLoading = true, mySongsList = listOf(), ) } private fun LibraryScreenState.onPublicFilterSelected(): LibraryScreenState { isLiked = null isPublic = true currentPage = 0 fetchPage() return this.copy( isLoading = true, mySongsList = listOf(), ) } private fun LibraryScreenState.onPrivateFilterSelected(): LibraryScreenState { isLiked = null isPublic = false currentPage = 0 fetchPage() return this.copy( isLoading = true, mySongsList = listOf(), ) } private fun LibraryScreenState.onLikedFilterSelected(): LibraryScreenState { isLiked = true isPublic = null currentPage = 0 fetchPage() return this.copy( isLoading = true, mySongsList = listOf(), ) } // todo: use this for resetting when navigated back to private fun LibraryScreenState.resetLibrary(): LibraryScreenState { currentPage = 0 isLiked = null isPublic = null fetchPage() return this.copy( isLoading = true, mySongsList = listOf(), ) } private fun LibraryScreenState.onSongClicked( song: SongListData, ): LibraryScreenState { val songList = this.mySongsList.map { it.asLocalClipData() } val chosenSong = songList.first { it.mediaUrl == song.mediaUrl } // Build filters list based on current library filter state val filters = buildList { when { isLiked == true -> add(LibraryFilter.Liked) isPublic == true -> add(LibraryFilter.Public) isPublic == false -> add(LibraryFilter.Private) } } // Store listening source for analytics listeningSourceCache.put( clipId = chosenSong.clipId.map(), listeningSource = ListeningSource.Library(filters = filters), ) mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) return this } private fun LibraryScreenState.onSongOverflowOpened( songId: Id, ): LibraryScreenState = this.copy(songIdToOperate = songId) private fun LibraryScreenState.handleSongDeleted( deletedSong: SongListData, ): LibraryScreenState { mediaManager.removeClipById(deletedSong.id) val songToDelete = this.mySongsList.firstOrNull { it.id == deletedSong.id } return songToDelete?.let { val updatedSongs = this.mySongsList - songToDelete this.copy( deletedSong = songToDelete, mySongsList = updatedSongs, ) } ?: this } private fun LibraryScreenState.refreshSongList(): LibraryScreenState { currentPage = 0 fetchPage( onResponse = { updateState { it.copy(isRefreshing = false) } }, replaceList = true, ) return this.copy(isRefreshing = true) } private fun fetchPage( onResponse: (Response) -> Unit = {}, replaceList: Boolean = false, ) { feedService.getFeedV2( page = currentPage, isLiked = isLiked, isPublic = isPublic, hideDisliked = true, hideStudioClips = true, hideGenStems = true, ).onEach { response -> handleFeedResponse(response = response, replaceList = replaceList) onResponse(response) }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } private fun fetchPageForLikedSongs() { viewModelScope.launch { val playlist = playlistService.getPlaylistById(LIKED_PLAYLIST_ID, currentPage).getOrElse { error -> logger.e(error.toThrowable()) return@launch } handlePlaylist(playlist) } } private fun handleFeedResponse( response: Response, replaceList: Boolean = false, ) { if (response.isSuccessful) { if (state.value.viewingContent != ViewingContent.MySongs) { // Don't update the list when the user is viewing other content return } val body: GetFeedV2ResponseEntity? = response.body() val clips: List? = body?.clips?.sortedByDescending { it.createdAt.xToDate() } val songListData: List = clips?.mapNotNull { clip: RemoteClip -> clip.xAsSongListDataOrNull() } ?: listOf() updateState { oldState -> val oldSongs = oldState.mySongsList val upsertedSongs = if (replaceList) { songListData } else { oldSongs.upsert(songListData) { it.id }.sortedByDescending { it.createdAt } } oldState.copy( isLoading = false, mySongsList = upsertedSongs, totalSongResultsNum = body?.numTotalResults, generatingSongIds = dedupeOnDisplayedSongIds( generatingClipIds = oldState.generatingSongIds, songList = upsertedSongs, ), ) } } else { // handle errors } } private fun handlePlaylist( playlist: PlaylistSchema, ) { if (state.value.viewingContent == ViewingContent.LikedSongs && playlist.id != LIKED_PLAYLIST_ID) { // don't update the list when a user navigate to other content return } val playlistClips = playlist.playlistClips val songListData = playlistClips.mapNotNull { remoteClip -> val clip = remoteClip.clip val reaction = when (clip.clipReaction?.reactionType) { "L" -> ReactionType.LIKE "D" -> ReactionType.DISLIKE else -> null } clip.xAsSongListDataOrNull()?.copy(reaction = reaction) } updateState { oldState -> val oldSongs = oldState.mySongsList val upsertedSongs = oldSongs.upsert(songListData) { it.id } oldState.copy(isLoading = false, mySongsList = upsertedSongs) } } private fun LibraryScreenState.handleSongRenamed( renamedSong: SongListData, ): LibraryScreenState { val localClipData = renamedSong.xAsLocalClipData() mediaManager.updateClip( localClipData, localClipData.copy(nowPlayingTitle = renamedSong.title), ) val updatedSongs = this.mySongsList.map { if (it.id == renamedSong.id) renamedSong else it } return this.copy(mySongsList = updatedSongs) } private fun undoDelete( songToUndoDelete: SongListData, ) { genService.trashGen( trashSpec = TrashSpec( clipIds = listOf(songToUndoDelete.id.value), trash = false, ), ).onEach { response -> if (response.isSuccessful) { updateState { oldState -> val updatedSongs = (oldState.mySongsList + songToUndoDelete).sortedByDescending { it.createdAt } oldState.copy(deletedSong = null, mySongsList = updatedSongs) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) } override suspend fun reduceEvent( currentState: LibraryScreenState, event: LibraryScreenEvent, emitEffect: suspend (LibraryScreenEffect) -> Unit, ): LibraryScreenState = when (event) { is LibraryScreenEvent.OnSongClicked -> { currentState.onSongClicked(event.song) } is LibraryScreenEvent.OnSongOverflowOpened -> { currentState.onSongOverflowOpened(event.songId) } is LibraryScreenEvent.OnSongDeleted -> { emitEffect(LibraryScreenEffect.ShowSnackbar(Type.SongDeleted)) currentState.handleSongDeleted(event.deletedSong) } is LibraryScreenEvent.OnSongRenamed -> { currentState.handleSongRenamed(event.renamedSong) } is LibraryScreenEvent.OnSongUndoDeleted -> { undoDelete(event.songToUndoDelete) // undoDelete updates state internally currentState } is LibraryScreenEvent.OnAllFilterSelected -> { currentState.onAllFilterSelected() } is LibraryScreenEvent.OnPublicFilterSelected -> { currentState.onPublicFilterSelected() } is LibraryScreenEvent.OnPrivateFilterSelected -> { currentState.onPrivateFilterSelected() } is LibraryScreenEvent.OnLikedFilterSelected -> { currentState.onLikedFilterSelected() } is LibraryScreenEvent.OnRefreshSongList -> { currentState.refreshSongList() } is LibraryScreenEvent.OnFetchNextPage -> { fetchNextPage() // fetchNextPage updates state internally currentState } is LibraryScreenEvent.OnFetchNextPageForLikedSongs -> { fetchNextPageForLikedSongs() // fetchNextPageForLikedSongs updates state internally currentState } is LibraryScreenEvent.OnResetLibrary -> { currentState.resetLibrary() } is LibraryScreenEvent.OnSnackbarAction -> { when (event.snackbarType) { Type.SongDeleted -> state.value.deletedSong?.let(::undoDelete) is Type.SongGenerationFailed -> Unit // no-op } currentState } } }