package com.suno.android.media.hooks import androidx.media3.common.Player 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_analytics.hooks.HookClipAnalyticsSession import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.clock.FakeClock import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.media.hooks.HooksFeedPlayerManager.HookPauseReason import com.suno.android.media.hooks.preload.HooksFeedPreloadManager import com.suno.android.media.player.pool.PlayerPool import io.mockk.Runs import io.mockk.clearMocks import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyOrder import org.junit.After import org.junit.Before import org.junit.Test import java.time.LocalDateTime import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class HooksFeedPlayerManagerTest { private val playerPool = mockk(relaxed = true) private val hooksFeedPreloadManager = mockk(relaxed = true) private val playCountManager = mockk(relaxed = true) private val hookAnalyticsManager = mockk(relaxed = true) private val clipAnalyticsManager = mockk(relaxed = true) private val clock = FakeClock(startingTime = 1000L.milliseconds) private val loggerFactory = mockk(relaxed = true) private lateinit var subject: HooksFeedPlayerManager @Before fun setUp() { subject = HooksFeedPlayerManager( loggerFactory = loggerFactory, playerPool = playerPool, hooksFeedPreloadManager = hooksFeedPreloadManager, playCountManager = playCountManager, hookAnalyticsManager = hookAnalyticsManager, clipAnalyticsManager = clipAnalyticsManager, clock = clock, ) } @After fun tearDown() { unmockkAll() } @Test fun `given first hook when starts playing then tracks play new hook event with TTFF and marks as watched`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) subject.playMedia(hook.hookId) listenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 150L.milliseconds) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PlayNewHook( hook = hook, isHookMuted = true, timeToFirstFrame = 150.milliseconds, ), ) } verify { playCountManager.markAsWatched(hook.hookId) } } @Test fun `given subsequent hook when starts playing then tracks play hook event with TTFF and marks as watched`() { val firstHook = createTestHookData(hookId = "hook1") val secondHook = createTestHookData(hookId = "hook2") val firstPlayer = createMockPlayer() val secondPlayer = createMockPlayer() val firstListenerSlot = slot() val secondListenerSlot = slot() every { firstPlayer.addListener(capture(firstListenerSlot)) } just Runs every { secondPlayer.addListener(capture(secondListenerSlot)) } just Runs every { secondPlayer.currentPosition } returnsMany listOf(0L) every { playerPool.assignPlayer(firstHook.hookId.map(), any()) } returns Result.success(firstPlayer) every { playerPool.assignPlayer(secondHook.hookId.map(), any()) } returns Result.success(secondPlayer) subject.prepareMedia(hook = firstHook, index = 0) firstListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 100L.milliseconds) firstListenerSlot.captured.onIsPlayingChanged(true) clock.advanceTimeBy(duration = 500L.milliseconds) subject.prepareMedia(hook = secondHook, index = 0) secondListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 200L.milliseconds) secondListenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PlayHook( hook = secondHook, playStartMillis = Duration.ZERO, isHookMuted = true, timeToFirstFrame = 200.milliseconds, ), ) } verify { playCountManager.markAsWatched(secondHook.hookId) } } @Test fun `given hook is playing when paused then tracks pause hook event with correct duration`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(7000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) listenerSlot.captured.onIsPlayingChanged(false) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PauseHook( hook = hook, playStartMillis = 7000.milliseconds, playEndMillis = 7000.milliseconds, isHookMuted = true, ), ) } } @Test fun `given hook paused when different hook index then does not track pause event`() { val firstHook = createTestHookData(hookId = "hook1") val secondHook = createTestHookData(hookId = "hook2") val firstPlayer = createMockPlayer() val secondPlayer = createMockPlayer() val firstListenerSlot = slot() val secondListenerSlot = slot() every { firstPlayer.addListener(capture(firstListenerSlot)) } just Runs every { secondPlayer.addListener(capture(secondListenerSlot)) } just Runs every { playerPool.assignPlayer(firstHook.hookId.map(), any()) } returns Result.success(firstPlayer) every { playerPool.assignPlayer(secondHook.hookId.map(), any()) } returns Result.success(secondPlayer) subject.prepareMedia(hook = firstHook, index = 0) firstListenerSlot.captured.onIsPlayingChanged(true) subject.prepareMedia(hook = secondHook, index = 1) secondListenerSlot.captured.onIsPlayingChanged(true) firstListenerSlot.captured.onIsPlayingChanged(false) verify(exactly = 0) { hookAnalyticsManager.track(ofType()) } } @Test fun `given same index transition when hook resumes playing then tracks handle play hook event with TTFF`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(1000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 100L.milliseconds) listenerSlot.captured.onIsPlayingChanged(true) clearMocks(hookAnalyticsManager, answers = false) clock.advanceTimeBy(duration = 300L.milliseconds) listenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 85L.milliseconds) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.PlayHook( hook = hook, playStartMillis = 1000.milliseconds, isHookMuted = true, timeToFirstFrame = 85.milliseconds, ), ) } } @Test fun `given hook transitions when scrolling down then tracks scroll down events with TTFF and correct duration`() { val firstHook = createTestHookData(hookId = "hook1") val secondHook = createTestHookData(hookId = "hook2") val firstPlayer = createMockPlayer() val secondPlayer = createMockPlayer() val firstListenerSlot = slot() val secondListenerSlot = slot() every { firstPlayer.addListener(capture(firstListenerSlot)) } just Runs every { secondPlayer.addListener(capture(secondListenerSlot)) } just Runs every { firstPlayer.currentPosition } returnsMany listOf(0L, 5000L, 10000L) every { secondPlayer.currentPosition } returnsMany listOf(1000L) every { playerPool.assignPlayer(firstHook.hookId.map(), any()) } returns Result.success(firstPlayer) every { playerPool.assignPlayer(secondHook.hookId.map(), any()) } returns Result.success(secondPlayer) subject.prepareMedia(hook = firstHook, index = 0) firstListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 80L.milliseconds) firstListenerSlot.captured.onIsPlayingChanged(true) clock.advanceTimeBy(duration = 300L.milliseconds) subject.prepareMedia(hook = secondHook, index = 1) secondListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 120L.milliseconds) secondListenerSlot.captured.onIsPlayingChanged(true) verifyOrder { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollDownPauseHook( hook = firstHook, playStartMillis = 5_000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = true, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollDownPlayNewHook( hook = secondHook, playStartMillis = 1000.milliseconds, isHookMuted = true, timeToFirstFrame = 120.milliseconds, ), ) } } @Test fun `given hook transitions when scrolling up then tracks scroll up events with TTFF and correct duration`() { val firstHook = createTestHookData(hookId = "hook1") val secondHook = createTestHookData(hookId = "hook2") val firstPlayer = createMockPlayer() val secondPlayer = createMockPlayer() val firstListenerSlot = slot() val secondListenerSlot = slot() every { firstPlayer.addListener(capture(firstListenerSlot)) } just Runs every { secondPlayer.addListener(capture(secondListenerSlot)) } just Runs every { firstPlayer.currentPosition } returnsMany listOf(0L, 5000L, 10_000L) every { secondPlayer.currentPosition } returnsMany listOf(1000L) every { playerPool.assignPlayer(firstHook.hookId.map(), any()) } returns Result.success(firstPlayer) every { playerPool.assignPlayer(secondHook.hookId.map(), any()) } returns Result.success(secondPlayer) subject.prepareMedia(hook = firstHook, index = 1) firstListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 90L.milliseconds) firstListenerSlot.captured.onIsPlayingChanged(true) clock.advanceTimeBy(duration = 400L.milliseconds) subject.prepareMedia(hook = secondHook, index = 0) secondListenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 110L.milliseconds) secondListenerSlot.captured.onIsPlayingChanged(true) verifyOrder { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollUpPauseHook( hook = firstHook, playStartMillis = 5_000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = true, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.ScrollUpPlayNewHook( hook = secondHook, playStartMillis = 1000.milliseconds, isHookMuted = true, timeToFirstFrame = 110.milliseconds, ), ) } } @Test fun `given hook auto repeats when media item transitions then tracks auto repeat events`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.duration } returns 30_000L every { player.currentPosition } returnsMany listOf(2000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) listenerSlot.captured.onMediaItemTransition(mockk(), Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT) verifyOrder { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.AutoRepeatPauseHook( hook = hook, playStartMillis = 2000.milliseconds, playEndMillis = 30_000.milliseconds, isHookMuted = true, ), ) hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.AutoRepeatPlayHook(hook = hook, isHookMuted = true), ) } } @Test fun `given hook prepared when clearMedia called then removes listener and unassigns player`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) subject.clearMedia(hook.hookId) verify { player.removeListener(listenerSlot.captured) } verify { playerPool.unassignPlayer(hook.hookId.map()) } } @Test fun `given multiple hooks prepared when clearAll called then releases all resources and clears state`() { val firstHook = createTestHookData(hookId = "hook1") val secondHook = createTestHookData(hookId = "hook2") val firstPlayer = createMockPlayer() val secondPlayer = createMockPlayer() every { playerPool.assignPlayer(firstHook.hookId.map(), any()) } returns Result.success(firstPlayer) every { playerPool.assignPlayer(secondHook.hookId.map(), any()) } returns Result.success(secondPlayer) subject.prepareMedia(hook = firstHook, index = 0) subject.prepareMedia(hook = secondHook, index = 1) subject.clearAll() verify { firstPlayer.removeListener(any()) } verify { secondPlayer.removeListener(any()) } verify { playerPool.releaseAllPlayers() } verify { playCountManager.flushPlayCounts() } } @Test fun `given clip playing when clip player is closed then tracks close clip player start hook event with TTFF`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() val clipSessionId = Id("clip-session-123") every { player.currentPosition } returnsMany listOf(2000L) every { player.addListener(capture(listenerSlot)) } just Runs every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) every { clipAnalyticsManager.getCurrentSessionId() } returns clipSessionId subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 70L.milliseconds) listenerSlot.captured.onIsPlayingChanged(true) subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.ClipPlayerOpen) every { clipAnalyticsManager.getCurrentSessionId() } returns clipSessionId clock.advanceTimeBy(duration = 200L.milliseconds) listenerSlot.captured.onPlayWhenReadyChanged(true, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) clock.advanceTimeBy(duration = 130L.milliseconds) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.CloseClipPlayerPlayHook( hook = hook, playStartMillis = 2_000.milliseconds, previousClipSessionId = clipSessionId, isHookMuted = true, timeToFirstFrame = 130.milliseconds, ), ) } } @Test fun `given hook playing when clip player is opened then tracks open clip player pause hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 5000L, 10_000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.ClipPlayerOpen) listenerSlot.captured.onIsPlayingChanged(false) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapSongPillPauseHook( hook = hook, playStartMillis = 5000.milliseconds, playEndMillis = 10_000.milliseconds, isHookMuted = true, ), ) } } @Test fun `given hook playing when profile is tapped then tracks profile pause hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 3000L, 8000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.ProfileTapAway) listenerSlot.captured.onIsPlayingChanged(false) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapProfilePauseHook( hook = hook, playStartMillis = 3000.milliseconds, playEndMillis = 8000.milliseconds, isHookMuted = true, ), ) } } @Test fun `given hook paused when navigating back from profile then tracks tap back play hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 2000L, 7000L, 7000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) // Start playing subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) // Pause when navigating to profile subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.ProfileTapAway) listenerSlot.captured.onIsPlayingChanged(false) // Resume when navigating back from profile subject.playMedia(hookId = hook.hookId, isMuted = true) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track(any()) } } @Test fun `given hook playing when explore tab is tapped then tracks tap tab pause hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 1500L, 6000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.TabTapAway) listenerSlot.captured.onIsPlayingChanged(false) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapTabPauseHook( hook = hook, playStartMillis = 1500.milliseconds, playEndMillis = 6000.milliseconds, isHookMuted = true, ), ) } } @Test fun `given hook paused when navigating back from explore tab then tracks tap tab play hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 2500L, 5500L, 5500L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) // Start playing subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) // Pause when tapping to explore tab subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.TabTapAway) listenerSlot.captured.onIsPlayingChanged(false) // Resume when navigating back from explore tab subject.playMedia(hookId = hook.hookId, isMuted = true) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track(any()) } } @Test fun `given hook playing when open create then tracks tap create pause hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 2000L, 9000L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.CreateTapAway) listenerSlot.captured.onIsPlayingChanged(false) verify { hookAnalyticsManager.track( HookAnalyticsPlaybackEvent.TapCreateSongPauseHook( hook = hook, playStartMillis = 2000.milliseconds, playEndMillis = 9000.milliseconds, isHookMuted = true, ), ) } } @Test fun `given hook paused when navigating back from create then tracks close create play hook event`() { val hook = createTestHookData(hookId = "hook1") val player = createMockPlayer() val listenerSlot = slot() every { player.addListener(capture(listenerSlot)) } just Runs every { player.currentPosition } returnsMany listOf(0L, 1800L, 6200L, 6200L) every { playerPool.assignPlayer(any(), any()) } returns Result.success(player) // Start playing subject.prepareMedia(hook = hook, index = 0) listenerSlot.captured.onIsPlayingChanged(true) // Pause when navigating to create screen subject.pauseMedia(hookId = hook.hookId, reason = HookPauseReason.CreateTapAway) listenerSlot.captured.onIsPlayingChanged(false) // Resume when navigating back from create screen subject.playMedia(hookId = hook.hookId, isMuted = true) listenerSlot.captured.onIsPlayingChanged(true) verify { hookAnalyticsManager.track(any()) } } private fun createTestHookData( hookId: String = "test-hook-id", creatorUserId: String = "creator-user-id", ): LocalHookData = LocalHookData( hookId = Id(hookId), title = "Test Hook Title", caption = "Test Hook Caption", videoUrl = Url("https://example.com/video.mp4"), thumbnailImageUrl = Url("https://example.com/thumb.jpg"), viewCount = 150, likeCount = 42, commentCount = 10, currentUserLiked = false, currentUserFollowsCreator = false, creator = LocalHookData.Creator( userId = Id(creatorUserId), handle = UserHandle("@testuser"), avatarUrl = Url("https://example.com/avatar.jpg"), displayName = "Test User", ), clip = mockk(), recommendationItemId = Id("rec-123"), createdAt = LocalDateTime.now(), humanRating = null, lyricDisplayType = null, lyrics = null, clipTime = 1.seconds..15.seconds, ) private fun createMockPlayer(): Player = mockk(relaxed = true) { every { currentPosition } returns 5000L every { duration } returns 30000L } }