package com.suno.android.ui.screens.search import androidx.lifecycle.viewModelScope import com.suno.android.common_analytics.listening_source.ListeningSource.Search import com.suno.android.common_analytics.listening_source.ListeningSourceCache 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.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.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.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @OptIn(FlowPreview::class) @HiltViewModel class ExploreSearchScreenVM @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, private val listeningSourceCache: ListeningSourceCache, featureManager: FeatureManager, ) : MviViewModel( processorFactory = processorFactory, initialState = ExploreSearchScreenState( isShowShareVideoGateEnabled = featureManager.hasFeature(Feature.ShowShareVideo), ), ) { // 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) } }, ) override suspend fun reduceEvent( currentState: ExploreSearchScreenState, event: ExploreSearchScreenEvent, emitEffect: suspend (ExploreSearchScreenEffect) -> Unit, ): ExploreSearchScreenState = when (event) { is ExploreSearchScreenEvent.SearchResult -> { // Build search cache key matching web format, format: searchType|query|rankBy val searchKey = getSearchKey() when (event) { is ExploreSearchScreenEvent.SearchResult.OnSongClicked -> { val songList = currentState.songResults.items.map { it.xAsLocalClipData() } val chosenSong = songList.first { it.mediaUrl == event.song.mediaUrl } // Store listening source for analytics listeningSourceCache.put( clipId = chosenSong.clipId.map(), listeningSource = Search(searchKey = searchKey), ) mediaManager.setCurrentlyPlayingPlaylist( songList = songList, chosenSong = chosenSong, ) } is ExploreSearchScreenEvent.SearchResult.OnCreatorClicked -> { emitEffect( ExploreSearchScreenEffect.NavigateToCreatorProfile( userHandle = event.userHandle, searchKey = searchKey, ), ) } is ExploreSearchScreenEvent.SearchResult.OnPlaylistClicked -> { emitEffect( ExploreSearchScreenEffect.NavigateToPlaylist( playlistId = event.playlistId, searchKey = searchKey, ), ) } } currentState } is ExploreSearchScreenEvent.OnSongOverflowOpened -> { currentState.copy( songIdToOperate = event.songId, ) } is ExploreSearchScreenEvent.OnSongRenamed -> { val localClipData = event.renamedSong.xAsLocalClipData() mediaManager.updateClip( localClipData, localClipData.copy(nowPlayingTitle = event.renamedSong.title), ) val updatedSongs = currentState.songResults.items.map { if (it.id == event.renamedSong.id) event.renamedSong else it }.toImmutableList() currentState.copy( songResults = currentState.songResults.copy( items = updatedSongs, ), ) } is ExploreSearchScreenEvent.OnSongDeleted -> { mediaManager.removeClipById(event.deletedSong.id) val songToDelete = currentState.songResults.items.firstOrNull { it.id == event.deletedSong.id } songToDelete?.let { val updatedSongs = (currentState.songResults.items - songToDelete).toImmutableList() currentState.copy( deletedSong = songToDelete, songResults = currentState.songResults.copy( items = updatedSongs, ), ) } ?: currentState } is ExploreSearchScreenEvent.OnUndoDeleteSong -> { genService.trashGen( trashSpec = TrashSpec( clipIds = listOf(event.songToUndoDelete.id.value), trash = false, ), ).onEach { response -> if (response.isSuccessful) { updateState { oldState -> val updatedSongs = (oldState.songResults.items + event.songToUndoDelete) .sortedByDescending { it.createdAt }.toImmutableList() oldState.copy( deletedSong = null, songResults = currentState.songResults.copy( items = updatedSongs, ), ) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) currentState } is ExploreSearchScreenEvent.OnUpdateSearchQuery -> { currentState.withUpdateQuery( query = event.query, ) } is ExploreSearchScreenEvent.OnSelectSearchCategory -> { currentState.withUpdateQuery( category = event.category, ) } is ExploreSearchScreenEvent.OnScrollToEndOfList -> { val activeSearch = currentState.currentCache.activeSearch if (activeSearch != null || currentState.currentCache.hasReachedEndOfList) { currentState } else { currentState.withUpdateCacheForCategory { it.copy( activeSearch = it.lastSearch?.nextPage() ?: ExploreSearchScreenState.SearchParams( query = currentState.query, category = currentState.selectedCategory, ), ) } } } } fun triggerSnackbar( message: String, ) { emitEffect(ExploreSearchScreenEffect.ShowSnackbar(message)) } fun isPlayerVisible(): Boolean = mediaManager.hasPlayedAnyClip() init { songGenerationStateStore.songGenerationStateFlow().onEach { songGenerationState -> refreshingSongGenPollingFlowExecutor.trigger() }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) mediaMetadataManager.mediaRemixabilityFlow().onEach { canRemix -> state.value.songResults.items.find { it.id == canRemix.clipId }?.let { song -> if (song.canRemix != canRemix.canRemix) { updateState { oldState -> val songResults = oldState.songResults.copy( items = oldState.songResults.items.map { if (it.id == song.id) it.copy(canRemix = canRemix.canRemix) else it }.toImmutableList(), ) oldState.copy(songResults = songResults) } } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) startSearchListener() } private var searchJob: Job? = null /** * Build search cache key matching web format. * Format: searchType|query|rankBy|isPublic|isInstrumental|modelVersion * * Matches web implementation: * getSearchCacheKey = () => { * return [ * this.searchType, * this.query, * this.rankBy, * this.isPublic, * this.is_instrumental === undefined ? '' : this.is_instrumental, * this.model_version === undefined ? '' : this.model_version, * ].join('|'); * }; */ private fun getSearchKey(): String { val currentState = state.value return buildString { append(currentState.selectedCategory.name) // "Songs", "Creators", "Playlists" append("|") append(currentState.query) append("|") append(SearchRankingEnum.most_relevant.value) // always most_relevant append("|") append("") // isPublic - not used in mobile search append("|") append("") // is_instrumental - not implemented in mobile yet append("|") append("") // model_version - not implemented in mobile yet } } private fun startSearchListener() { searchJob?.cancel() searchJob = state.mapNotNull { Pair( it.currentCache.lastSearch, it.currentCache.activeSearch ?: return@mapNotNull null, ) }.distinctUntilChanged().debounce { (lastRequest, activeRequest) -> if (lastRequest != null && activeRequest.query.isNotBlank() && lastRequest.query != activeRequest.query) { 200.milliseconds } else { Duration.ZERO } }.map { it.second } // end search query debounce chain .flatMapLatest { currentSearch -> val searchQuery = SearchQuerySchema( searchType = currentSearch.category.toSearchTypeEnum(), term = currentSearch.query, rankBy = SearchRankingEnum.most_relevant, propertySize = currentSearch.pageSize, fromIndex = currentSearch.fromIndex, ) searchService.postSearch( SearchRequest( searchQueries = listOf(searchQuery), ), ).onEach { response -> val baseResult = response.body()?.result?.get("") updateState { oldState -> var updatedSongs = oldState.songResults var updatedUsers = oldState.userResults var updatedPlaylists = oldState.playlistResults val hasReachedEndOfList = baseResult?.totalHits?.let { it < currentSearch.pageSize } ?: true when (searchQuery.searchType) { SearchTypeEnum.PublicSong -> { val clips = baseResult?.result ?: emptyList() val songResults = clips.mapNotNull { result: ResultInner -> result.xAsSongListDataOrNull() }.toImmutableList() updatedSongs = updatedSongs.withNewResult( newRequest = currentSearch, newData = songResults, hasReachedEndOfList = hasReachedEndOfList, ) } SearchTypeEnum.User -> { val users = baseResult?.result?.toImmutableList() ?: persistentListOf() updatedUsers = updatedUsers.withNewResult( newRequest = currentSearch, newData = users, hasReachedEndOfList = hasReachedEndOfList, ) } SearchTypeEnum.Playlist -> { val playlists = baseResult?.result?.toImmutableList() ?: persistentListOf() updatedPlaylists = updatedPlaylists.withNewResult( newRequest = currentSearch, newData = playlists, hasReachedEndOfList = hasReachedEndOfList, ) } else -> Unit } oldState.copy( songResults = updatedSongs, userResults = updatedUsers, playlistResults = updatedPlaylists, ) } }.catch { exception -> logger.e(exception) } }.launchIn(viewModelScope) } } private fun ExploreSearchScreenState.Category.toSearchTypeEnum() = when (this) { ExploreSearchScreenState.Category.Songs -> SearchTypeEnum.PublicSong ExploreSearchScreenState.Category.Creators -> SearchTypeEnum.User ExploreSearchScreenState.Category.Playlists -> SearchTypeEnum.Playlist }