package com.suno.android.ui.screens.home.library.search import androidx.lifecycle.viewModelScope import com.suno.android.common_core_utils.helpers.PeriodicFlowExecutor import com.suno.android.common_data.generation.SongGenerationStateStore import com.suno.android.common_data.mappers.clips.xAsLocalClipData import com.suno.android.common_data.mappers.clips.xAsSongListDataOrNull import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel 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.SearchTypeEnum 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.gen.GenService import com.suno.android.common_networking.remote.search.SearchService 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 dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import javax.inject.Inject @OptIn(FlowPreview::class) @HiltViewModel class LibrarySearchScreenVM @Inject constructor( processorFactory: MviProcessorFactory, private val searchService: SearchService, private val feedService: FeedService, private val genService: GenService, private val mediaManager: MediaManager, private val mediaMetadataManager: MediaMetadataManager, private val songGenerationStateStore: SongGenerationStateStore, featureManager: FeatureManager, ) : MviViewModel( processorFactory = processorFactory, initialState = LibrarySearchScreenState( isShowShareVideoGateEnabled = featureManager.hasFeature(Feature.ShowShareVideo), ), ) { private var fullListSize: Int? = null private var unableToLoadMore = false private val _searchQueryFlow = MutableStateFlow(null) // TODO: refactor this up into LoggedInNavGraph as ssot private val refreshingSongGenPollingFlowExecutor = PeriodicFlowExecutor( scope = viewModelScope, flowProvider = { val generatingClipIds = songGenerationStateStore.songGenerationStateFlow().value.generatingClipIds if (generatingClipIds.isNotEmpty()) { feedService.getSongsWithIdsFlow(clipIds = generatingClipIds.joinToString(",")) } else { flowOf(null) } }, ) init { songGenerationStateStore.songGenerationStateFlow() .onEach { songGenerationState -> refreshingSongGenPollingFlowExecutor.trigger() }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) _searchQueryFlow.debounce(1000L).filterNotNull() .onEach { query -> sendEvent(LibrarySearchScreenEvent.OnSearchLibrary(term = query)) }.launchIn(viewModelScope) mediaMetadataManager.mediaRemixabilityFlow().onEach { canRemix -> state.value.songResults.find { it.id == canRemix.clipId }?.let { song -> if (song.canRemix != canRemix.canRemix) { updateState { oldState -> val updatedSong = song.copy(canRemix = canRemix.canRemix) val updatedSongs = oldState.songResults.map { if (it.id == song.id) updatedSong else it } oldState.copy(songResults = updatedSongs) } } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) sendEvent(LibrarySearchScreenEvent.OnSearchLibrary(term = "")) } fun triggerSnackbar( message: String, ) { emitEffect(LibrarySearchScreenEffect.ShowSnackbar(message = message)) } override suspend fun reduceEvent( currentState: LibrarySearchScreenState, event: LibrarySearchScreenEvent, emitEffect: suspend (LibrarySearchScreenEffect) -> Unit, ): LibrarySearchScreenState { return when (event) { is LibrarySearchScreenEvent.OnSongClicked -> { val songList = currentState.songResults.map { it.asLocalClipData() } val chosenSong = songList.first { it.mediaUrl == event.song.mediaUrl } mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) currentState } is LibrarySearchScreenEvent.OnSongOverflowOpened -> { return currentState.copy( songIdToOperate = event.songId, ) } is LibrarySearchScreenEvent.OnSongRenamed -> { val localClipData = event.renamedSong.xAsLocalClipData() mediaManager.updateClip( localClipData, localClipData.copy(nowPlayingTitle = event.renamedSong.title), ) val updatedSongs = currentState.songResults.map { if (it.id == event.renamedSong.id) event.renamedSong else it } currentState.copy(songResults = updatedSongs) } is LibrarySearchScreenEvent.OnSongDeleted -> { mediaManager.removeClipById(event.deletedSong.id) val songToDelete = currentState.songResults.firstOrNull { it.id == event.deletedSong.id } songToDelete?.let { val updatedSongs = currentState.songResults - songToDelete currentState.copy( deletedSong = songToDelete, songResults = updatedSongs, ) } ?: currentState } is LibrarySearchScreenEvent.OnUpdateSearchQuery -> { _searchQueryFlow.update { event.query } currentState } is LibrarySearchScreenEvent.OnUndoDeleteSong -> { genService.trashGen( trashSpec = TrashSpec( clipIds = listOf(event.songToUndoDelete.id.value), trash = false, ), ).onEach { response -> if (response.isSuccessful) { updateState { oldState -> val updatedSongs = (oldState.songResults + event.songToUndoDelete).sortedByDescending { it.createdAt } oldState.copy(deletedSong = null, songResults = updatedSongs) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is LibrarySearchScreenEvent.OnSearchLibrary -> { if (event.increment && unableToLoadMore) return currentState updateState { oldState -> if (event.increment) { oldState.copy( loadingMore = true, ) } else { oldState.copy( loading = true, ) } } val fromIndex = if (event.increment) { currentState.songResults.size } else { 0 } val isDefaultQuery = event.term.isEmpty() val searchQuery = SearchQuerySchema( searchType = SearchTypeEnum.LibrarySong, term = event.term, rankBy = if (isDefaultQuery) SearchRankingEnum.most_recent else SearchRankingEnum.most_relevant, propertySize = event.pageSize, fromIndex = fromIndex, ) searchService.postSearch( searchRequest = SearchRequest( searchQueries = listOf(searchQuery), ), ).onEach { response -> val baseResult = response.body()?.result?.get("") val clips = baseResult?.result ?: emptyList() fullListSize = baseResult?.totalHits val songResults = clips.mapNotNull { result -> result.xAsSongListDataOrNull() } updateState { oldState -> val updatedSongs = if (event.increment) { oldState.songResults + songResults } else { songResults } if (fullListSize != null && updatedSongs.size >= (fullListSize ?: -1)) { unableToLoadMore = true } oldState.copy( loading = false, loadingMore = false, resultsCount = baseResult?.totalHits, songResults = updatedSongs, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } } } fun isPlayerVisible(): Boolean = mediaManager.hasPlayedAnyClip() }