package com.suno.android.media.hooks import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.suno.android.common_analytics.hooks.HookAnalyticsManager import com.suno.android.common_analytics.hooks.HookAnalyticsPlaybackEvent import com.suno.android.common_analytics.hooks.HookClipAnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.clock.Clock import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.media.HooksFeed import com.suno.android.media.hooks.preload.HooksFeedPreloadManager import com.suno.android.media.player.pool.PlayerPool import com.suno.android.media.player.pool.PlayerPoolMedia import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** * Manages media playback operations for the hooks feed feature. * Provides a high-level interface for media preparation, playback control, and cleanup. */ @OptIn(UnstableApi::class) @Singleton class HooksFeedPlayerManager @Inject constructor( loggerFactory: SunoLogger.Factory, @HooksFeed private val playerPool: PlayerPool, private val hooksFeedPreloadManager: HooksFeedPreloadManager, private val playCountManager: HooksPlayCountManager, private val hookAnalyticsManager: HookAnalyticsManager, private val clipAnalyticsManager: HookClipAnalyticsManager, private val clock: Clock, ) : PlayerPool.Listener { private val logger = loggerFactory.create(this@HooksFeedPlayerManager) private val hookPlayerStates = ConcurrentHashMap, HookPlayerState>() private val hasPlayedAnyHook = AtomicBoolean(false) private val previousSessionPauseReason = AtomicReference(null) private val currentHookId = AtomicReference>(null) init { playerPool.setListener(this) } fun onHooksLoaded( hooks: List, ) { hooksFeedPreloadManager.addHooks(hooks) } fun getPlayerStateFlow( hook: LocalHookData, index: Int, ): StateFlow = hookPlayerStates.getOrPut(hook.hookId) { HookPlayerState( hook = hook, index = index, ) }.player fun prepareMedia( hook: LocalHookData, index: Int, ) { if (hookPlayerStates[hook.hookId]?.player?.value != null) { logger.d { "prepareMedia(): player already assigned for hook id: ${hook.hookId}" } return } playerPool.assignPlayer(mediaId = hook.hookId.map(), index = index) .mapCatching { player -> val listener = createPlayerListener(hook.hookId).also(player::addListener) hookPlayerStates.compute(hook.hookId) { _, currentState -> val state = currentState ?: HookPlayerState( hook = hook, index = index, playStartPositionMillis = Duration.ZERO, ) state.player.tryEmit(player) state.copy(listener = listener) } val mediaItem = hook.toMediaItem() val mediaSource = hooksFeedPreloadManager.getMediaSource(mediaItem) if (mediaSource != null && player is ExoPlayer) { player.setMediaSource(mediaSource) } else { player.setMediaItem(mediaItem) } player.prepare() player.setMuted(isMuted = true) player } .onFailure { logger.e(it) { "Failed to assign player for hook id: ${hook.hookId.value}" } } } private fun createPlayerListener( hookId: Id, ): Player.Listener = object : Player.Listener { override fun onIsPlayingChanged( isPlaying: Boolean, ) { super.onIsPlayingChanged(isPlaying) val playbackStartedTimestamp = if (isPlaying) clock.currentTime else null val currentHookState = hookPlayerStates.computeIfPresent(hookId) { _, state -> state.copy(playbackStartedTimestamp = playbackStartedTimestamp) } ?: run { logger.w { "Player not registered for hook id: $hookId" } return } val previousHookState = currentHookId.get()?.let(hookPlayerStates::get) logger.d { val previousHookId = previousHookState?.hook?.hookId?.value "onIsPlayingChanged - is playing: $isPlaying, " + "current hook: (id: ${hookId.value}, index: ${currentHookState.index}), " + "previous hook: (id: $previousHookId, index: ${previousHookState?.index})" } if (isPlaying) { handleIsPlaying( currentHookState = currentHookState, previousHookState = previousHookState, ) } else { handleIsNotPlaying( currentHookState = currentHookState, previousHookState = previousHookState, ) } } private fun handleIsPlaying( currentHookState: HookPlayerState, previousHookState: HookPlayerState?, ) { currentHookId.set(currentHookState.hook.hookId) playCountManager.markAsWatched(currentHookState.hook.hookId) hooksFeedPreloadManager.updateCurrentlyPlayingIndex(currentHookState.index) playerPool.updateCurrentlyPlayingIndex(currentHookState.index) when { previousHookState == null -> handlePlayHook(currentHookState) currentHookState.index > previousHookState.index -> handleScrollDown( currentHookState = currentHookState, previousHookState = previousHookState, ) currentHookState.index < previousHookState.index -> handleScrollUp( currentHookState = currentHookState, previousHookState = previousHookState, ) else -> handlePlayHook(currentHookState) } } private fun handleScrollDown( currentHookState: HookPlayerState, previousHookState: HookPlayerState, ) { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollDownPauseHook( hook = previousHookState.hook, playStartMillis = previousHookState.playStartPositionMillis, playEndMillis = (previousHookState.player.value?.currentPosition ?: 0L).milliseconds, isHookMuted = previousHookState.isPlayerMuted, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollDownPlayNewHook( hook = currentHookState.hook, playStartMillis = (currentHookState.player.value?.currentPosition ?: 0L).milliseconds, isHookMuted = currentHookState.isPlayerMuted, timeToFirstFrame = currentHookState.calculateTimeToFirstFrame(), ), ) } private fun handleScrollUp( currentHookState: HookPlayerState, previousHookState: HookPlayerState, ) { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollUpPauseHook( hook = previousHookState.hook, playStartMillis = previousHookState.playStartPositionMillis, playEndMillis = (previousHookState.player.value?.currentPosition ?: 0L).milliseconds, isHookMuted = previousHookState.isPlayerMuted, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollUpPlayNewHook( hook = currentHookState.hook, playStartMillis = (currentHookState.player.value?.currentPosition ?: 0L).milliseconds, isHookMuted = currentHookState.isPlayerMuted, timeToFirstFrame = currentHookState.calculateTimeToFirstFrame(), ), ) } private fun handleIsNotPlaying( currentHookState: HookPlayerState, previousHookState: HookPlayerState?, ) { if (currentHookState.index != previousHookState?.index) return val isHookMuted = currentHookState.isPlayerMuted val currentPosition = currentHookState.player.value?.currentPosition?.milliseconds ?: Duration.ZERO when (currentHookState.pauseReason) { HookPauseReason.ClipPlayerOpen -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapSongPillPauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = currentPosition, isHookMuted = isHookMuted, ), ) HookPauseReason.TabTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapTabPauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = currentPosition, isHookMuted = isHookMuted, ), ) HookPauseReason.CreateTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapCreateSongPauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = currentPosition, isHookMuted = isHookMuted, ), ) HookPauseReason.ProfileTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapProfilePauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = currentPosition, isHookMuted = isHookMuted, ), ) HookPauseReason.CreatorHidden, HookPauseReason.TapToPause, HookPauseReason.Other, null, -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = currentPosition, isHookMuted = isHookMuted, ), ) HookPauseReason.SwipeAway -> { // This is handled by handleScrollDown and handleScrollUp } } } override fun onPlayWhenReadyChanged( playWhenReady: Boolean, reason: Int, ) { super.onPlayWhenReadyChanged(playWhenReady, reason) logger.d { "onPlayWhenReadyChanged($playWhenReady) - hook id: ${hookId.value}" } val playIntentTimestamp = if (playWhenReady) clock.currentTime else null hookPlayerStates.computeIfPresent(hookId) { _, state -> state.copy(playIntentTimestamp = playIntentTimestamp) } } override fun onMediaItemTransition( mediaItem: MediaItem?, reason: Int, ) { super.onMediaItemTransition(mediaItem, reason) if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) { val currentHookState = hookPlayerStates[hookId] ?: return handleAutoRepeat(currentHookState) } } private fun handleAutoRepeat( currentHookState: HookPlayerState, ) { val isHookMuted = currentHookState.isPlayerMuted val hookDuration = (currentHookState.player.value?.duration ?: 0L).milliseconds hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.AutoRepeatPauseHook( hook = currentHookState.hook, playStartMillis = currentHookState.playStartPositionMillis, playEndMillis = hookDuration, isHookMuted = isHookMuted, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.AutoRepeatPlayHook( hook = currentHookState.hook, isHookMuted = isHookMuted, ), ) hookPlayerStates.computeIfPresent(currentHookState.hook.hookId) { _, _ -> currentHookState.copy( playStartPositionMillis = Duration.ZERO, ) } } } private fun handlePlayHook( currentHookState: HookPlayerState, ) { val isHookMuted = currentHookState.isPlayerMuted val currentPositionMillis = currentHookState.player.value?.currentPosition?.milliseconds ?: Duration.ZERO val previousPauseReason = previousSessionPauseReason.getAndSet(null) val timeToFirstFrame = currentHookState.calculateTimeToFirstFrame() if (hasPlayedAnyHook.compareAndSet(false, true)) { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = currentHookState.hook, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) } else { when (currentHookState.pauseReason ?: previousPauseReason) { HookPauseReason.ClipPlayerOpen -> clipAnalyticsManager.getCurrentSessionId()?.let { clipSessionId -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.CloseClipPlayerPlayHook( hook = currentHookState.hook, playStartMillis = currentPositionMillis, previousClipSessionId = clipSessionId, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) } HookPauseReason.TabTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapTabPlayHook( hook = currentHookState.hook, playStartMillis = currentPositionMillis, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) HookPauseReason.CreateTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.CloseCreateSongPlayHook( hook = currentHookState.hook, playStartMillis = currentPositionMillis, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) HookPauseReason.ProfileTapAway -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapBackPlayHook( hook = currentHookState.hook, playStartMillis = currentPositionMillis, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) HookPauseReason.SwipeAway, HookPauseReason.TapToPause, HookPauseReason.Other, null, -> hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PlayHook( hook = currentHookState.hook, playStartMillis = currentPositionMillis, isHookMuted = isHookMuted, timeToFirstFrame = timeToFirstFrame, ), ) HookPauseReason.CreatorHidden -> { // No-op } } } hookPlayerStates.computeIfPresent(currentHookState.hook.hookId) { _, _ -> currentHookState.copy( playStartPositionMillis = (currentHookState.player.value?.currentPosition ?: 0L).milliseconds, pauseReason = null, ) } } fun playMedia( hookId: Id, isMuted: Boolean = false, ) { logger.d { "playMedia, hook id: ${hookId.value}" } hookPlayerStates[hookId]?.player?.value?.let { player -> player.setMuted(isMuted) player.play() } } fun pauseMedia( hookId: Id, reason: HookPauseReason, ) { logger.d { "pauseMedia, hook id: ${hookId.value}, reason: $reason" } // Update pauseReason 1st, then pause player 2nd, so Player.Listener.onIsPlayingChanged can receive pauseReason // via hookPlayerStates val hookPlayerState = hookPlayerStates.computeIfPresent(hookId) { _, state -> state.copy(pauseReason = reason) } hookPlayerState?.player?.value?.pause() // Store the pause reason for use after potential clearAll() calls previousSessionPauseReason.set(reason) } fun setMediaMuted( hookId: Id, isMuted: Boolean, ) { logger.d { "setMediaMuted, hook id: ${hookId.value}, isMuted: $isMuted" } hookPlayerStates[hookId]?.player?.value?.setMuted(isMuted) } fun clearMedia( hookId: Id, ) { logger.d { "clearMedia, hook id: ${hookId.value}" } val removedState = hookPlayerStates.remove(hookId) removedState?.let { state -> state.listener?.let { state.player.value?.removeListener(it) } state.player.tryEmit(null) } playerPool.unassignPlayer(hookId.map()) } fun clearAll() { logger.d { "clearAll" } currentHookId.set(null) hookPlayerStates.values.forEach { state -> state.listener?.let { state.player.value?.removeListener(it) } state.player.tryEmit(null) } hookPlayerStates.clear() playerPool.releaseAllPlayers() flushPlayCounts() hooksFeedPreloadManager.clear() } fun flushPlayCounts() { logger.d { "flushPlayCounts" } playCountManager.flushPlayCounts() } override fun onPlayerEvicted( evictedMediaId: Id, ) { val hookId = evictedMediaId.map() hookPlayerStates[hookId]?.let { state -> state.listener?.let { state.player.value?.removeListener(it) } state.player.tryEmit(null) } } private fun Player.setMuted( isMuted: Boolean, ) { volume = if (isMuted) 0f else 1f } private val HookPlayerState.isPlayerMuted: Boolean get() = player.value?.volume == 0f private fun HookPlayerState.calculateTimeToFirstFrame(): Duration? = if (playIntentTimestamp != null && playbackStartedTimestamp != null) { val duration = playbackStartedTimestamp - playIntentTimestamp duration.takeUnless { it.isNegative() } } else { null } private data class HookPlayerState( val hook: LocalHookData, val index: Int, val player: MutableStateFlow = MutableStateFlow(null), val listener: Player.Listener? = null, val playStartPositionMillis: Duration = Duration.ZERO, val pauseReason: HookPauseReason? = null, val playIntentTimestamp: Duration? = null, val playbackStartedTimestamp: Duration? = null, ) enum class HookPauseReason { ClipPlayerOpen, TabTapAway, CreateTapAway, ProfileTapAway, CreatorHidden, SwipeAway, TapToPause, Other, } }