package com.suno.android.ui.screens.hooks.feed import androidx.annotation.OptIn import androidx.annotation.StringRes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import arrow.core.Either import arrow.core.flatMap import arrow.core.getOrElse import arrow.retrofit.adapter.either.networkhandling.CallError 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.helpers.AppLifecycleManager import com.suno.android.common_core_utils.helpers.SunoAppLifecycleEvent import com.suno.android.common_core_utils.model.UiString import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.managers.HookDownloadManager import com.suno.android.common_data.mappers.hooks.HooksFeed import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.repos.HideCreatorContentType import com.suno.android.common_data.repos.HooksRepository import com.suno.android.common_data.repos.ProfilesRepository import com.suno.android.common_data.repos.RecommendationsRepository import com.suno.android.common_data.repos.ShareLinkRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_mvi.MviController import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_networking.extensions.ApiResult import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.entities.RemoteHookReactionBody.Action import com.suno.android.common_networking.remote.entities.RemoteHookReactionBody.TapType 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.hooks.HooksFeatureGateManager import com.suno.android.hooks.HooksFeedTab import com.suno.android.hooks.HooksFeedType import com.suno.android.media.MediaManager import com.suno.android.media.hooks.HooksFeedPlayerManager import com.suno.android.media.hooks.toRecommendationMetadata import com.suno.android.ui.screens.home.tabs.BottomNavTabItem import com.suno.android.ui.screens.home.tabs.BottomTabBarManager import com.suno.android.ui.screens.hooks.clip.HookClipPlayerController import com.suno.android.ui.screens.hooks.feed.HooksFeedEffect.NavigateToProfile import com.suno.android.ui.screens.hooks.feed.HooksFeedUiState.BottomSheetState import com.suno.android.ui.screens.hooks.feed.HooksFeedUiState.BottomSheetState.ClipPlayerVisible import com.suno.android.ui.screens.hooks.feed.HooksFeedUiState.BottomSheetState.MoreMenuVisible import com.suno.android.ui.screens.hooks.feed.HooksFeedUiState.BottomSheetState.RemixTypeSelectionVisible import com.suno.android.ui.screens.navigation.NavDestination import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR private const val LOAD_MORE_THRESHOLD = 3 /** * Controller managing the hooks feed screen state and video playback coordination. */ @OptIn(UnstableApi::class) @Suppress("LargeClass", "TooManyFunctions") class HooksFeedController @AssistedInject constructor( processorFactory: MviProcessorFactory, @Assisted coroutineScope: CoroutineScope, @Assisted private val source: HooksFeedSource, @Assisted private val startIndex: Int, private val hooksRepository: HooksRepository, private val profilesRepository: ProfilesRepository, private val shareLinkRepository: ShareLinkRepository, private val recommendationsRepository: RecommendationsRepository, private val userSessionRepository: UserSessionRepository, private val playerManager: HooksFeedPlayerManager, private val mediaManager: MediaManager, private val topLevelErrorManager: TopLevelErrorManager, private val clipPlayerControllerFactory: HookClipPlayerController.Factory, private val appLifecycleManager: AppLifecycleManager, hooksFeatureGateManager: HooksFeatureGateManager, statsigManager: StatsigFeatureDataSource, private val bottomTabBarManager: BottomTabBarManager, private val hookDownloadManager: HookDownloadManager, ) : MviController( processorFactory = processorFactory, coroutineScope = coroutineScope, initialState = HooksFeedUiState( currentIndex = startIndex, isShowHooksCreateEntryPointGateEnabled = hooksFeatureGateManager.isHooksCreateEnabled, isRemixClipGateEnabled = statsigManager.checkGate(FeatureGate.REMIX_CLIP), isFeedMuted = source is HooksFeedSource.Home && when (hooksFeatureGateManager.hooksFeedTab) { HooksFeedTab.First -> true HooksFeedTab.Second -> hooksFeatureGateManager.hooksFeedType == HooksFeedType.Carousel HooksFeedTab.Disabled -> false }, isHookDownloadGateEnabled = hooksFeatureGateManager.isHooksDownloadEnabled, isHookDownloadSelfGateEnabled = hooksFeatureGateManager.isHooksDownloadSelfEnabled, ), ) { @AssistedFactory interface Factory { fun create( coroutineScope: CoroutineScope, source: HooksFeedSource, startIndex: Int, ): HooksFeedController } private var isInitialized = false private fun initialize() { if (isInitialized) return isInitialized = true logger.d { "Initializing" } observeAppLifecycleEvents() observeUserSession() fetchCurrentHookLyrics() sendEvent(HooksFeedEvent.LoadFeed()) } private fun observeAppLifecycleEvents() { appLifecycleManager.appProcessStateFlow() .filter { it == SunoAppLifecycleEvent.OnAppBackgrounded } .onEach { handleAppBackgrounded() } .launchIn(controllerScope) } private fun fetchCurrentHookLyrics() { state .map { currentState -> currentState.currentHook?.takeUnless { hook -> hook.lyrics != null } } .filterNotNull() .distinctUntilChanged() .onEach { hook -> val hookId = hook.hookId val result = hooksRepository.getHooksLyrics(listOf(hookId)) .getOrElse { error -> logger.w { "failed to fetch hooks lyrics $error" } return@onEach } val lyrics = result[hookId] ?: return@onEach val offsetLyrics = lyrics.withOffset(-hook.clipTime.start) sendEvent( HooksFeedEvent.LyricsLoaded( hookId = hookId, lyrics = offsetLyrics, ), ) } .launchIn(controllerScope) } private fun handleAppBackgrounded() { playerManager.flushPlayCounts() updateState { oldState -> val currentHookId = oldState.currentHook?.hookId ?: return@updateState oldState playerManager.setMediaMuted( hookId = currentHookId, isMuted = true, ) oldState.copy(isFeedMuted = !oldState.userPausedHooks.contains(currentHookId)) } } private fun observeUserSession() { userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> val user = sessionConfiguration.user updateState { oldState -> val currentUserHandle = user?.handle?.let { UserHandle(it) } oldState.copy(currentUserHandle = currentUserHandle) } } .launchIn(controllerScope) } override suspend fun reduceEvent( currentState: HooksFeedUiState, event: HooksFeedEvent, emitEffect: suspend (HooksFeedEffect) -> Unit, ): HooksFeedUiState = when (event) { is HooksFeedEvent.LoadFeed -> { if (currentState.isLoading) { currentState } else { if (event.resetScroll) { emitEffect(HooksFeedEffect.ResetScroll) } loadFeed() currentState.copy(isLoading = true, hasError = false) } } is HooksFeedEvent.CurrentPageChanged -> { if (!currentState.isLoadingMore && event.currentPage >= currentState.hooks.size - LOAD_MORE_THRESHOLD) { loadMore() currentState.copy(isLoadingMore = true) } else { currentState } } is HooksFeedEvent.HookFocused -> { // Only pause previous hook when switching to a different hook (actual swipe). // This preserves tab navigation pause reasons for analytics. if (currentState.currentHook?.hookId != event.hook.hookId) { currentState.currentHook?.hookId?.let { hookId -> playerManager.pauseMedia( hookId = hookId, reason = HooksFeedPlayerManager.HookPauseReason.SwipeAway, ) } } if (!isCreatorHidden(event.hook)) { playerManager.playMedia( hookId = event.hook.hookId, isMuted = currentState.isFeedMuted, ) } val updatedUserPausedHooks = (currentState.userPausedHooks - event.hook.hookId).toImmutableSet() currentState.copy( currentIndex = event.index, userPausedHooks = updatedUserPausedHooks, captionExpandedHookId = null, ) } is HooksFeedEvent.TogglePlayback -> { when { event.hookId != currentState.currentHook?.hookId -> currentState currentState.userPausedHooks.contains(event.hookId) -> { playerManager.playMedia( hookId = event.hookId, isMuted = false, ) val updatedUserPausedHooks = (currentState.userPausedHooks - event.hookId).toImmutableSet() currentState.copy( userPausedHooks = updatedUserPausedHooks, isFeedMuted = false, ) } else -> { playerManager.pauseMedia( hookId = event.hookId, reason = HooksFeedPlayerManager.HookPauseReason.TapToPause, ) val updatedUserPausedHooks = (currentState.userPausedHooks + event.hookId).toImmutableSet() currentState.copy(userPausedHooks = updatedUserPausedHooks) } } } is HooksFeedEvent.ToggleLike -> { val currentlyLiked = event.hook.currentUserLiked toggleHookLike( hook = event.hook, currentlyLiked = currentlyLiked, isDoubleTap = false, ) currentState.updateHook(event.hook.hookId) { currentHook -> currentHook.copy( currentUserLiked = !currentlyLiked, likeCount = if (currentlyLiked) currentHook.likeCount - 1 else currentHook.likeCount + 1, ) } } is HooksFeedEvent.DoubleTapLike -> { if (!event.hook.currentUserLiked) { toggleHookLike( hook = event.hook, currentlyLiked = false, isDoubleTap = true, ) currentState.updateHook(event.hook.hookId) { currentHook -> currentHook.copy( currentUserLiked = true, likeCount = currentHook.likeCount + 1, ) } } else { currentState } } is HooksFeedEvent.ShowCommentsSheet -> { currentState.copy(showCommentsSheet = true) } is HooksFeedEvent.OnCommentBeginWrite -> currentState HooksFeedEvent.CommentsSheetDismissed -> { currentState.copy(showCommentsSheet = false) } is HooksFeedEvent.ToggleFollowCreator -> { val userHandle = event.hook.creator.handle if (userHandle != null) { val currentlyFollowing = event.hook.currentUserFollowsCreator toggleFollowCreator( userHandle = userHandle, currentlyFollowing = currentlyFollowing, hook = event.hook, ) val updatedHooks = currentState.hooks.map { hook -> if (hook.creator.handle == userHandle) { hook.copy(currentUserFollowsCreator = !currentlyFollowing) } else { hook } } currentState.copy(hooks = updatedHooks) } else { logger.w { "Null user handle received in ToggleFollowCreator event for hook id: ${event.hook.hookId}" } currentState } } is HooksFeedEvent.NavigateToCreatorProfile -> { if (event.handle != null) { controllerScope.launch { emitEffect(NavigateToProfile(event.handle)) } currentState.copy(isInNestedCreatorNavigation = true) } else { logger.w { "Received NavigateToCreatorProfile event with null handle" } currentState } } is HooksFeedEvent.ShowShareSheet -> { currentState.copy(showShareSheet = true) } HooksFeedEvent.DismissShareSheet -> { currentState.copy(showShareSheet = false) } is HooksFeedEvent.ShowRemixSelectionSheet -> { currentState.copy( bottomSheetState = RemixTypeSelectionVisible(event.hook.clip), ) } is HooksFeedEvent.StartShare -> { startShare(event.hook, event.platform) currentState.copy(showShareSheet = false) } is HooksFeedEvent.ShowClipPlayer -> { state.value.currentHook?.hookId?.let { hookId -> if (!currentState.userPausedHooks.contains(hookId)) { playerManager.pauseMedia( hookId = hookId, reason = HooksFeedPlayerManager.HookPauseReason.ClipPlayerOpen, ) } } val controller = clipPlayerControllerFactory.create( coroutineScope = controllerScope, hook = event.hook, ) currentState.copy(bottomSheetState = ClipPlayerVisible(controller)) } HooksFeedEvent.OnResume -> { initialize() mediaManager.setIsPlaying(isPlaying = false) currentState.currentHook?.let { hook -> if (!currentState.userPausedHooks.contains(hook.hookId) && !isCreatorHidden(hook)) { playerManager.playMedia( hookId = hook.hookId, isMuted = currentState.isFeedMuted, ) } } currentState.copy(isInNestedCreatorNavigation = false) } HooksFeedEvent.OnPause -> { currentState.currentHook?.hookId?.let { hookId -> val pauseReason = if (currentState.isInNestedCreatorNavigation) { HooksFeedPlayerManager.HookPauseReason.ProfileTapAway } else { when (bottomTabBarManager.getCurrentTab()) { // If OnPause event fired while on HooksFeed, scroll or app backgrounded BottomNavTabItem.HooksFeed.destination -> HooksFeedPlayerManager.HookPauseReason.Other is NavDestination.Create.CreateNavGraphDestination -> HooksFeedPlayerManager.HookPauseReason.CreateTapAway else -> HooksFeedPlayerManager.HookPauseReason.TabTapAway } } playerManager.pauseMedia( hookId = hookId, reason = pauseReason, ) } currentState } HooksFeedEvent.OnDispose -> { playerManager.clearAll() currentState } is HooksFeedEvent.ShowAddToPlaylistSheet -> { currentState.copy(bottomSheetState = BottomSheetState.AddToPlaylist(event.hook)) } is HooksFeedEvent.OnCloseBottomSheetRequested -> { if (currentState.bottomSheetState is BottomSheetState.ClipPlayerVisible) { state.value.currentHook?.let { hook -> if (!currentState.userPausedHooks.contains(hook.hookId)) { playerManager.playMedia(hook.hookId) } emitAnalyticEffect( HooksFeedAnalyticEffect.OnClipPlayerDismiss( hook = hook, dismissalAction = event.action, ), ) } currentState.bottomSheetState.controller.onClear() } currentState.copy(bottomSheetState = BottomSheetState.Hidden) } is HooksFeedEvent.ShowMoreMenu -> { val isOwnHook = currentState.currentUserHandle?.handle?.isNotBlank() == true && event.hook.creator.handle == currentState.currentUserHandle val isDownloadEnabled = currentState.isHookDownloadGateEnabled || (currentState.isHookDownloadSelfGateEnabled && isOwnHook) currentState.copy( bottomSheetState = MoreMenuVisible( hook = event.hook, isOwnHook = isOwnHook, isDownloadEnabled = isDownloadEnabled, ), ) } is HooksFeedEvent.GoToFullSong -> { state.value.currentHook?.hookId?.let { hookId -> if (!currentState.userPausedHooks.contains(hookId)) { playerManager.pauseMedia( hookId = hookId, reason = HooksFeedPlayerManager.HookPauseReason.ClipPlayerOpen, ) } } val controller = clipPlayerControllerFactory.create( coroutineScope = controllerScope, hook = event.hook, ) currentState.copy(bottomSheetState = ClipPlayerVisible(controller)) } is HooksFeedEvent.DownloadHook -> { hookDownloadManager.startDownload( hook = event.hook, ) currentState.copy(bottomSheetState = BottomSheetState.Hidden) } is HooksFeedEvent.ReportInappropriate -> { reportInappropriate(event.hook) currentState.copy(bottomSheetState = BottomSheetState.Hidden) } is HooksFeedEvent.HideCreator -> { hideCreator(event.hook) val creatorHandle = event.hook.creator.handle if (creatorHandle != null) { playerManager.pauseMedia( hookId = event.hook.hookId, reason = HooksFeedPlayerManager.HookPauseReason.CreatorHidden, ) currentState.copy( bottomSheetState = BottomSheetState.Hidden, hiddenCreators = (currentState.hiddenCreators + creatorHandle).toImmutableSet(), ) } else { currentState.copy(bottomSheetState = BottomSheetState.Hidden) } } is HooksFeedEvent.NotInterested -> { setHookNotInterested(event.hook) currentState.copy(bottomSheetState = BottomSheetState.Hidden) } HooksFeedEvent.ScrollCarouselToUnmute -> { currentState.currentHook?.let { hook -> playerManager.setMediaMuted( hookId = hook.hookId, isMuted = false, ) emitAnalyticEffect(HooksFeedAnalyticEffect.OnFeedExpandTapped(hook)) emitAnalyticEffect(HooksFeedAnalyticEffect.OnUnmuteFromCarousel(hook)) } currentState.copy(isFeedMuted = false) } HooksFeedEvent.TapToUnmute -> { currentState.currentHook?.let { hook -> playerManager.setMediaMuted( hookId = hook.hookId, isMuted = false, ) emitAnalyticEffect(HooksFeedAnalyticEffect.OnUnmuteFromFullscreen(hook)) } currentState.copy(isFeedMuted = false) } HooksFeedEvent.DismissOnboardingModalToUnmute -> { currentState.currentHook?.let { hook -> playerManager.setMediaMuted( hookId = hook.hookId, isMuted = false, ) } currentState.copy(isFeedMuted = false) } HooksFeedEvent.MuteFeedFromCarouselExpand -> { currentState.currentHook?.let { hook -> playerManager.setMediaMuted( hookId = hook.hookId, isMuted = true, ) emitAnalyticEffect(HooksFeedAnalyticEffect.OnMuteFromCarousel(hook)) } currentState.copy(isFeedMuted = true) } is HooksFeedEvent.ToggleCaptionExpansion -> { val hookId = event.hook.hookId if (currentState.captionExpandedHookId == hookId) { currentState.copy(captionExpandedHookId = null) } else { currentState.copy(captionExpandedHookId = hookId) } } is HooksFeedEvent.LyricsLoaded -> { if (currentState.currentHook?.hookId != event.hookId) currentState currentState.copy( hooks = currentState.hooks.map { hook -> if (hook.hookId == event.hookId) { hook.copy( lyrics = event.lyrics, ) } else { hook } }, ) } is HooksFeedEvent.SongPlaylistStatusChanged -> { val newEntry = event.songId to event.isInAnyPlaylist val updatedMap = (currentState.songPlaylistStatusMap + newEntry).toImmutableMap() currentState.copy(songPlaylistStatusMap = updatedMap) } } private fun toggleHookLike( hook: LocalHookData, currentlyLiked: Boolean, isDoubleTap: Boolean, ) { controllerScope.launch { val action = if (currentlyLiked) Action.Unlike else Action.Like val tapType = if (isDoubleTap) TapType.Double else TapType.Single hooksRepository.setHookReaction( hookId = hook.hookId, action = action, recommendationMetadata = hook.toRecommendationMetadata(), tapType = tapType, ).getOrElse { error -> updateState { oldState -> oldState.updateHook(hook.hookId) { oldHook -> oldHook.copy( currentUserLiked = hook.currentUserLiked, likeCount = hook.likeCount, ) } } logger.e(error.toThrowable()) { "Failed to toggle hook like" } } } } private fun toggleFollowCreator( userHandle: UserHandle, currentlyFollowing: Boolean, hook: LocalHookData, ) { controllerScope.launch { profilesRepository.followArtistProfile( handle = userHandle.handle, unfollow = currentlyFollowing, recommendationMetadata = hook.toRecommendationMetadata(), ).getOrElse { error -> updateState { oldState -> val updatedHooks = oldState.hooks.map { hook -> if (hook.creator.handle == userHandle) { hook.copy(currentUserFollowsCreator = currentlyFollowing) } else { hook } } oldState.copy(hooks = updatedHooks) } logger.e(error.toThrowable()) { "Failed to toggle creator follow" } } } } private fun loadFeed() { controllerScope.launch { loadHooks(isLoadMore = false) } } private fun loadMore() { controllerScope.launch { loadHooks(isLoadMore = true) } } private suspend fun loadHooks( isLoadMore: Boolean, ) { val startIndex = if (isLoadMore) { state.value.lastPage.startIndex + state.value.lastPage.pageSize } else { 0 } val getFeed: (suspend (Int) -> ApiResult) = when (source) { HooksFeedSource.Home -> { startIndex: Int -> hooksRepository.getHooksFeed(startIndex) } is HooksFeedSource.User -> { startIndex: Int -> hooksRepository.getUserVideoHooksV2(startIndex, userHandle = source.userHandle.handle) } is HooksFeedSource.Single -> { _ -> hooksRepository.getSingleHookFeed(source.hookId) } } val result = getFeed(startIndex) .getOrElse { error -> handleLoadError(error) return } .also { feed -> playerManager.onHooksLoaded(feed.hooks) logger.d { val hookIds = feed.hooks.map { it.hookId.value } "Loaded ${feed.hooks.size} hooks: $hookIds, is first page: ${!isLoadMore}" } } updateState { oldState -> val updatedHooks = if (isLoadMore) { val existingHookIds = oldState.hooks.mapTo(mutableSetOf()) { it.hookId } logger.d { val duplicateHookIds = result.hooks.mapNotNull { hook -> if (hook.hookId in existingHookIds) hook.hookId.value else null } "Filtered out ${duplicateHookIds.size} duplicate hooks: $duplicateHookIds" } buildList { addAll(oldState.hooks) result.hooks.filterTo(this) { it.hookId !in existingHookIds } } } else { result.hooks } val updatedLastPage = HooksFeedUiState.LastPage(startIndex, result.hooks.size).also { logger.d { "Updating last page: $it" } } oldState.copy( isLoading = false, isLoadingMore = false, hasCompletedInitialLoad = true, hooks = updatedHooks, hasError = false, errorMessage = "", currentIndex = if (isLoadMore) oldState.currentIndex else 0, lastPage = updatedLastPage, ) } } private fun handleLoadError( error: CallError, ) { logger.e(error.toThrowable()) updateState { oldState -> oldState.copy( isLoading = false, isLoadingMore = false, hasError = true, errorMessage = "Failed to load hooks feed", ) } } private fun startShare( hook: LocalHookData, platform: SharePlatformConstants, ) { when (platform) { is SharePlatformConstants.Link -> { controllerScope.launch { val shareLink = shareLinkRepository .getHookShareLink( contentId = hook.hookId, platform = platform.backendValue, recommendationMetadata = hook.toRecommendationMetadata(), ) .mapLeft(CallError::toThrowable) .flatMap { link -> link?.let { Either.Right(it) } ?: Either.Left(NullPointerException("Link is null")) } .getOrElse { error -> logger.e(error) { "Failed to get hook share URL" } showErrorDialog(CommonResR.string.share_link_error_body) return@launch } emitEffect( HooksFeedEffect.ShareLink( hookId = hook.hookId, sharePlatform = platform, link = shareLink, ), ) hooksRepository.incrementShareCount( hookId = hook.hookId, recommendationMetadata = hook.toRecommendationMetadata(), ).getOrElse { error -> logger.e(error.toThrowable()) { "Failed to increment share count" } } } } is SharePlatformConstants.Video -> { // Video sharing not supported for hooks } } } private fun reportInappropriate( hook: LocalHookData, ) { controllerScope.launch { hooksRepository.reportInappropriate( hookId = hook.hookId, recommendationMetadata = hook.toRecommendationMetadata(), ).getOrElse { error -> logger.e(error.toThrowable()) { "Failed to report hook" } return@launch } emitEffect(HooksFeedEffect.ShowSnackbar(HooksFeedEffect.ShowSnackbar.Type.HookReported)) } } private fun hideCreator( hook: LocalHookData, ) { val creatorHandle = hook.creator.handle if (creatorHandle == null) { logger.w { "Cannot hide creator: handle is null" } return } controllerScope.launch { recommendationsRepository.toggleHideCreator( creatorHandle = creatorHandle, contentType = HideCreatorContentType.Hook, hide = true, recommendationMetadata = hook.toRecommendationMetadata(), ).getOrElse { error -> logger.e(error) { "Failed to hide creator" } return@launch } } } private fun setHookNotInterested( hook: LocalHookData, ) { controllerScope.launch { hooksRepository.setHookReaction( hookId = hook.hookId, action = Action.Dislike, recommendationMetadata = hook.toRecommendationMetadata(), ).getOrElse { error -> logger.e(error.toThrowable()) { "Failed to set hook as not interested" } return@launch } val nextIndex = (state.value.currentIndex + 1).coerceAtMost(state.value.hooks.size - 1) emitEffect(HooksFeedEffect.NavigateToTargetHook(nextIndex)) emitEffect(HooksFeedEffect.ShowSnackbar(HooksFeedEffect.ShowSnackbar.Type.NotInterested)) } } override fun onClear() { super.onClear() playerManager.clearAll() } fun prepareMedia( hook: LocalHookData, index: Int, ) { playerManager.prepareMedia(hook, index) } fun getVideoPlayerState( hook: LocalHookData, index: Int, ): StateFlow = playerManager.getPlayerStateFlow(hook, index) fun handleHookDispose( hookId: Id, ) { playerManager.clearMedia(hookId) } fun openCommentsBottomSheet() { updateState { it.copy(showCommentsSheet = true) } } fun isCreatorHidden( hook: LocalHookData, ): Boolean = hook.creator.handle?.let { handle -> state.value.hiddenCreators.contains(handle) } ?: false private fun HooksFeedUiState.updateHook( hookId: Id, update: (LocalHookData) -> LocalHookData, ): HooksFeedUiState = copy( hooks = hooks.map { hook -> if (hook.hookId == hookId) { update(hook) } else { hook } }, ) private suspend fun showErrorDialog( @StringRes bodyResId: Int, ) { topLevelErrorManager.broadcastTopLevelDialogError( errorMessage = DialogSpec( body = UiString.Resource(bodyResId), buttons = persistentListOf( DialogSpec.Button( label = UiString.Resource(CommonResR.string.ok), type = DialogSpec.Button.DialogButtonType.DEFAULT, ), ), ), ) } }