package com.suno.android.media.hooks import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import com.suno.android.common_analytics.hooks.HookAnalyticsManager import com.suno.android.common_analytics.hooks.HookClipAnalyticsManager import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.media.HookClipPlayer import com.suno.android.media.player.SharedPlayer 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 private const val SCRUB_INCREMENT_MS = 10_000 @Singleton class HookClipMediaManager @Inject constructor( loggerFactory: SunoLogger.Factory, @HookClipPlayer private val sharedPlayer: SharedPlayer, private val hookClipAnalyticsManager: HookClipAnalyticsManager, private val hookAnalyticsManager: HookAnalyticsManager, ) { val player: Player get() = sharedPlayer.getInstance() val isPlaying: Boolean get() = player.isPlaying val currentPosition: Long get() = player.currentPosition val duration: Long get() = player.duration private val logger = loggerFactory.create(this@HookClipMediaManager) private val currentClipState = AtomicReference(null) private val hasTrackedInitialPlayback = AtomicBoolean(false) fun playClip( clip: LocalClipData, ) { val listener = createPlayerListener() currentClipState.set( ClipPlayerState( player = player, listener = listener, clip = clip, initialPlayStartPositionMillis = Duration.ZERO, accumulatedPlaybackMillis = Duration.ZERO, lastPlayPositionMillis = Duration.ZERO, ), ) player.addListener(listener) player.playClip(clip) } private fun createMediaItem( clip: LocalClipData, ): MediaItem { val metadata = MediaMetadata.Builder() .setTitle(clip.nowPlayingTitle) .setArtist(clip.artistName) .setArtworkUri(clip.albumImageUrl?.url?.toUri()) .build() return MediaItem.Builder() .setMediaId(clip.clipId.value) .setUri(clip.mediaUrl.url) .setMediaMetadata(metadata) .build() } fun pause() { player.pause() } fun resume() { player.play() } fun scrubForward() { seekTo(currentPosition + SCRUB_INCREMENT_MS) } fun scrubBackward() { seekTo(currentPosition - SCRUB_INCREMENT_MS) } fun seekTo( positionMs: Long, ) { player.seekTo(positionMs) } fun release() { hasTrackedInitialPlayback.set(false) val previousState = currentClipState.getAndSet(null) previousState?.let { state -> player.removeListener(state.listener) hookClipAnalyticsManager.trackCloseClipPlayerPauseSong( clip = state.clip, playStartMillis = state.initialPlayStartPositionMillis, playEndMillis = state.player.currentPosition.milliseconds, playDurationMillis = state.accumulatedPlaybackMillis, ) } player.reset() sharedPlayer.releaseInstance() } private fun createPlayerListener(): Player.Listener = object : Player.Listener { override fun onIsPlayingChanged( isPlaying: Boolean, ) { super.onIsPlayingChanged(isPlaying) logger.d { "onIsPlayingChanged - is playing: $isPlaying, clip id: ${currentClipState.get()?.clip?.clipId}" } if (isPlaying) { handleIsPlaying() } } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int, ) { super.onPositionDiscontinuity(oldPosition, newPosition, reason) logger.d { "onPositionDiscontinuity - seeking ${ if (newPosition.positionMs > oldPosition.positionMs) "forwards" else "backwards" }, clip id: ${currentClipState.get()?.clip?.clipId}" } // Handle seek events to accumulate playback duration if (reason == Player.DISCONTINUITY_REASON_SEEK) { currentClipState.getAndUpdate { state -> state?.let { // Theoretically will never have oldPosition < lastPlayPosition, since playback only goes // forward – coerce value for extra safety! val lastPlaybackDuration = (oldPosition.positionMs.milliseconds - state.lastPlayPositionMillis) .coerceAtLeast(Duration.ZERO) state.copy( lastPlayPositionMillis = newPosition.positionMs.milliseconds, accumulatedPlaybackMillis = state.accumulatedPlaybackMillis + lastPlaybackDuration, ) } } } } private fun handleIsPlaying() { if (hasTrackedInitialPlayback.compareAndSet(false, true)) { val previousState = currentClipState.getAndUpdate { state -> state?.copy(initialPlayStartPositionMillis = state.player.currentPosition.milliseconds) } previousState?.let { state -> hookAnalyticsManager.getCurrentSessionId()?.let { currentHookSessionId -> hookClipAnalyticsManager.trackOpenClipPlayerPlaySong( clip = state.clip, previousHookSessionId = currentHookSessionId, ) } } ?: run { val exception = IllegalStateException("Current state null when handling is playing") logger.e(exception) } } } } private fun Player.playClip( clip: LocalClipData, ) { val mediaItem = createMediaItem(clip) setMediaItem(mediaItem) prepare() play() } private fun Player.reset() { stop() clearMediaItems() } } /** * @param initialPlayStartPositionMillis timestamp where clip initially started playing on open * @param accumulatedPlaybackMillis accumulated playback duration, through seeking, pausing, etc * @param lastPlayPositionMillis previous timestamp from last playback changed, i.e. prev state to accumulate playback */ private data class ClipPlayerState( val player: Player, val listener: Player.Listener, val clip: LocalClipData, val initialPlayStartPositionMillis: Duration, val accumulatedPlaybackMillis: Duration, val lastPlayPositionMillis: Duration, )