package com.suno.android.ui.screens.hooks.clip import androidx.media3.common.Player import arrow.core.getOrElse import com.suno.android.clip.UpdateClipReactionUseCase 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.xAsMinutesSecondsTimecode 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.hooks.LocalHookData import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.repos.ShareLinkRepository import com.suno.android.common_mvi.MviController import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_ui.components.bottom_sheet.SharePlatformConstants import com.suno.android.gating.statsig.FeatureGate import com.suno.android.gating.statsig.StatsigFeatureDataSource import com.suno.android.media.MediaMetadataManager import com.suno.android.media.hooks.HookClipMediaManager import com.suno.android.media.hooks.toRecommendationMetadata import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds private const val PROGRESS_UPDATE_INTERVAL_MS = 100L class HookClipPlayerController @AssistedInject constructor( processorFactory: MviProcessorFactory, @Assisted coroutineScope: CoroutineScope, @Assisted hook: LocalHookData, statsigManager: StatsigFeatureDataSource, private val clipMediaManager: HookClipMediaManager, private val shareLinkRepository: ShareLinkRepository, private val updateClipReactionUseCase: UpdateClipReactionUseCase, private val clipsRepository: ClipsRepository, private val mediaMetadataManager: MediaMetadataManager, ) : MviController( processorFactory = processorFactory, coroutineScope = coroutineScope, initialState = HookClipPlayerUiState( hook = hook, isShowShareVideoGateEnabled = statsigManager.checkGate(FeatureGate.SHOW_SHARE_VIDEO), isRemixClipGateEnabled = statsigManager.checkGate(FeatureGate.REMIX_CLIP), ), ) { @AssistedFactory interface Factory { fun create( coroutineScope: CoroutineScope, hook: LocalHookData, ): HookClipPlayerController } private var progressUpdateJob: Job? = null private val playerListener = object : Player.Listener { override fun onIsPlayingChanged( isPlaying: Boolean, ) { updateState { currentState -> currentState.copy(isPlaying = isPlaying) } if (isPlaying) { startProgressTracking() } else { stopProgressTracking() } } override fun onPlaybackStateChanged( playbackState: Int, ) { when (playbackState) { Player.STATE_READY -> { updateState { currentState -> currentState.copy(isLoading = false) } } Player.STATE_BUFFERING -> { updateState { currentState -> currentState.copy(isLoading = true) } } else -> {} } } } init { clipMediaManager.player.addListener(playerListener) observeClipReactionUpdates() } private fun observeClipReactionUpdates() { mediaMetadataManager.mediaReactionFlow() .filter { state.value.clip.clipId == it.clipId } .onEach { mediaReaction -> if (state.value.clip.reaction != mediaReaction.reaction) { updateState { oldState -> val updatedClip = oldState.clip.copy(reaction = mediaReaction.reaction) val updatedHook = oldState.hook.copy(clip = updatedClip) oldState.copy(hook = updatedHook) } } } .launchIn(controllerScope) } override suspend fun reduceEvent( currentState: HookClipPlayerUiState, event: HookClipPlayerEvent, emitEffect: suspend (HookClipPlayerEffect) -> Unit, ): HookClipPlayerUiState = when (event) { HookClipPlayerEvent.StartPlayback -> { controllerScope.launch { clipMediaManager.playClip(currentState.clip) updateLyrics(currentState.clip) } currentState.copy(isPlaying = true) } HookClipPlayerEvent.PlayPause -> { if (currentState.isPlaying) { clipMediaManager.pause() } else { clipMediaManager.resume() } currentState.copy(isPlaying = !currentState.isPlaying) } is HookClipPlayerEvent.Seek -> { val seekPosition = (event.percentage * clipMediaManager.duration).toLong() clipMediaManager.seekTo(seekPosition) currentState.copy(progressPercentage = event.percentage) } HookClipPlayerEvent.ScrubForward -> { clipMediaManager.scrubForward() currentState } HookClipPlayerEvent.ScrubBackward -> { clipMediaManager.scrubBackward() currentState } HookClipPlayerEvent.ToggleLike -> { updateReaction(currentState, ReactionType.LIKE) } HookClipPlayerEvent.ShowComments -> { currentState.copy( bottomSheetState = HookClipPlayerUiState.BottomSheetState.CommentsVisible, ) } HookClipPlayerEvent.ShowShare -> { currentState.copy( bottomSheetState = HookClipPlayerUiState.BottomSheetState.ShareVisible( showShareVideo = currentState.isShowShareVideoGateEnabled, ), ) } HookClipPlayerEvent.ShowSongActions -> { currentState.copy( bottomSheetState = HookClipPlayerUiState.BottomSheetState.SongActionsVisible( showShareVideo = currentState.isShowShareVideoGateEnabled, ), ) } is HookClipPlayerEvent.StartShare -> { startShare(currentState.clip, event.platform, emitEffect) currentState.copy(showShareSheet = false) } is HookClipPlayerEvent.NavigateToProfile -> { emitEffect(HookClipPlayerEffect.NavigateToProfile(event.userHandle)) currentState } HookClipPlayerEvent.OnCloseBottomSheetRequested -> { currentState.copy(bottomSheetState = HookClipPlayerUiState.BottomSheetState.Hidden) } HookClipPlayerEvent.OnResume -> { if (!currentState.isPlaying) { clipMediaManager.resume() } currentState } HookClipPlayerEvent.OnPause -> { if (currentState.isPlaying) { clipMediaManager.pause() } currentState } HookClipPlayerEvent.ShowRemixSelectionSheet -> { currentState.copy( bottomSheetState = HookClipPlayerUiState.BottomSheetState.RemixTypeSelectionVisible, ) } } private suspend fun updateLyrics( clip: LocalClipData, ) { if (clip.displayLyrics != null) return val result = clipsRepository.getLyricsForClip(clip.clipId).getOrElse { error -> logger.e(error) return } val lyricsString = result.alignedWords?.joinToString("") { it.word } updateState { oldState -> val updatedClip = oldState.clip.copy(lyricsString = lyricsString) val updatedHook = oldState.hook.copy(clip = updatedClip) oldState.copy(hook = updatedHook) } } private fun updateReaction( currentState: HookClipPlayerUiState, requestedReactionType: ReactionType, ): HookClipPlayerUiState { controllerScope.launch { updateClipReactionUseCase( clipId = currentState.clip.clipId, currentReactionType = currentState.clip.reaction, requestedReactionType = requestedReactionType, recommendationMetadata = currentState.hook.toRecommendationMetadata(), ).getOrElse { error -> logger.e(error.toThrowable()) { "Failed to update reaction to $requestedReactionType" } updateState { oldState -> val updatedClip = oldState.clip.copy(reaction = currentState.clip.reaction) val updatedHook = oldState.hook.copy(clip = updatedClip) oldState.copy(hook = updatedHook) } } } val updatedReaction = if (currentState.clip.reaction == requestedReactionType) { null } else { requestedReactionType } val updatedClip = currentState.clip.copy(reaction = updatedReaction) val updatedHook = currentState.hook.copy(clip = updatedClip) return currentState.copy(hook = updatedHook) } private fun startShare( clip: LocalClipData, platform: SharePlatformConstants, emitEffect: suspend (HookClipPlayerEffect) -> Unit, ) { when (platform) { is SharePlatformConstants.Link -> startShareLink(clip.clipId, platform, emitEffect) is SharePlatformConstants.Video -> startShareVideo(clip.clipId, platform, emitEffect) } } private fun startShareLink( songId: Id, platform: SharePlatformConstants.Link, emitEffect: suspend (HookClipPlayerEffect) -> Unit, ) { controllerScope.launch { val shareLink = shareLinkRepository.getSongShareLink(songId, platform.backendValue).getOrElse { error -> logger.e(error.toThrowable()) return@launch } shareLink?.let { link -> emitEffect( HookClipPlayerEffect.ShareLink( clipId = songId, sharePlatform = platform, link = link, ), ) } } } private fun startShareVideo( songId: Id, platform: SharePlatformConstants.Video, emitEffect: suspend (HookClipPlayerEffect) -> Unit, ) { controllerScope.launch { emitEffect( HookClipPlayerEffect.ShareVideo( clipId = songId, sharePlatform = platform, ), ) } } private fun startProgressTracking() { stopProgressTracking() progressUpdateJob = controllerScope.launch { while (clipMediaManager.isPlaying) { val duration = clipMediaManager.duration if (duration > 0) { val currentPosition = clipMediaManager.currentPosition val percentage = currentPosition.toFloat() / duration.toFloat() val timestamp = currentPosition.milliseconds.xAsMinutesSecondsTimecode() updateState { currentState -> currentState.copy( progressPercentage = percentage.coerceIn(0f, 1f), timestamp = timestamp, ) } } delay(PROGRESS_UPDATE_INTERVAL_MS) } } } private fun stopProgressTracking() { progressUpdateJob?.cancel() progressUpdateJob = null } fun providePlayer(): Player = clipMediaManager.player override fun onClear() { stopProgressTracking() clipMediaManager.player.removeListener(playerListener) clipMediaManager.release() } }