package com.suno.android.media import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.Bundle import androidx.collection.LruCache import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import com.suno.android.common_core_utils.ApplicationCoroutineScope import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.constants.SunoMediaType import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_networking.extensions.ApiResult import com.suno.android.common_networking.remote.entities.PlaylistSchema import com.suno.android.common_networking.remote.playlist.PlaylistService import com.suno.android.common_res.R import com.suno.android.common_ui.components.omni.RepeatMode import com.suno.android.media.player.SharedPlayer import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.plus import javax.inject.Inject import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.times import kotlin.time.toDuration interface MediaManager { suspend fun loadFullPlaylistById( playlistId: String, ): ApiResult fun setCurrentlyPlayingPlaylist( songList: List, chosenSong: LocalClipData, enablePlayback: Boolean? = true, ) fun setSinglePlayingClipData( localClipData: LocalClipData, enablePlayback: Boolean? = true, ) fun getMediaPlayerState(): MediaPlayerState fun getMediaType(): SunoMediaType fun getPlayer(): Player fun hasPlayedAnyClip(): Boolean fun isPlaying(): Boolean fun isShuffling(): Boolean fun getRepeatMode(): RepeatMode fun setIsPlaying( isPlaying: Boolean, ) fun moveToNextSong() fun handleGoToPrevious() fun setIsShuffling( isShuffling: Boolean, ) fun cycleRepeatMode() fun mediaPlayerFlow(): Flow fun setCurrentlyPlayingIndex( index: Int?, ): LocalClipData? fun getCurrentlyPlayingIndex(): Int? fun setCurrentTimestamp( timeStampPosition: Duration, duration: Duration, ) fun scrubForward() fun scrubBackward() fun scrubToZero() fun scrubToPosition( timeStampPosition: Long, ) fun scrubToPercent( percent: Float, ) fun updateClip( oldLocalClipData: LocalClipData, newLocalClipData: LocalClipData, ) fun removeClipById( clipId: Id, ) fun stopPlaybackAndClearPlaylist() fun providePlayerListenerInterceptor(): Player.Listener fun getCastManager(): CastManager } class MediaManagerImpl @Inject constructor( loggerFactory: SunoLogger.Factory, @ApplicationContext private val context: Context, private val playlistService: PlaylistService, @CoreMediaPlayback private val sharedPlayer: SharedPlayer, private val castManager: CastManager, @ApplicationCoroutineScope coroutineScope: CoroutineScope, ) : MediaManager { private val logger = loggerFactory.create(this@MediaManagerImpl) private var mediaController: MediaController? = null private val _mediaPlayerStateFlow = MutableStateFlow(MediaPlayerState()) init { buildNotificationShadeController() castManager.setPlayerListener(providePlayerListenerInterceptor()) } private val exoPlayer by sharedPlayer private val activePlayer: Player get() = castManager.getCastPlayer() ?: exoPlayer // todo if MediaManager ever gets a release method, cancel this job in that method private val mediaManagerJob = SupervisorJob() private val mediaManagerCoroutineScope = coroutineScope + mediaManagerJob private val cachedTimeEstimates = LruCache, Duration>(MAX_CACHE_SIZE) private fun Player.durationOrEstimate(): Duration = this.duration.takeIf { it > 0 }?.milliseconds ?: run { val chunkSize = 30.seconds val chunkTarget = PLAYER_CHUNK_TARGET val usedChunks = (currentPosition.milliseconds / chunkSize).toInt() val usedChunksTime = usedChunks * chunkSize val timeIntoCurrentChunk = currentPosition.milliseconds - usedChunksTime val shownChunks = if (timeIntoCurrentChunk > chunkSize * chunkTarget) { usedChunks + 2 } else { usedChunks + 1 } val calculatedSize = shownChunks * chunkSize val mediaId = this.currentMediaItem?.mediaId?.let { Id(it) } val cached = mediaId?.let { cachedTimeEstimates[mediaId] } val estimatedDuration = maxOf(calculatedSize, cached ?: Duration.ZERO) if (mediaId != null) { cachedTimeEstimates.put(mediaId, estimatedDuration) } estimatedDuration } init { startStateUpdateListener() } private fun startStateUpdateListener() { // player must be accessed from the main thread val timestampUpdateJob = mediaManagerCoroutineScope.launch(Dispatchers.Main) { while (isActive) { if (activePlayer.isPlaying) { val currentPosition = activePlayer.currentPosition.milliseconds val duration = activePlayer.durationOrEstimate() setCurrentTimestamp( timeStampPosition = currentPosition, duration = duration, ) } delay(TIMESTAMP_UPDATE_JOB_DELAY) // Update every half second } } timestampUpdateJob.invokeOnCompletion { logger.println { "Timestamp update stopped." } } } private fun buildNotificationShadeController() { val sessionToken = SessionToken( context, ComponentName( context, PlaybackMediaLibraryService::class.java, ), ) val controllerFuture: ListenableFuture = MediaController.Builder( context, sessionToken, ).buildAsync() controllerFuture.addListener( { val mediaController: MediaController = controllerFuture.get() this.mediaController = mediaController }, MoreExecutors.directExecutor(), ) } override fun getMediaPlayerState(): MediaPlayerState = _mediaPlayerStateFlow.value override fun getPlayer(): Player = activePlayer override fun getMediaType(): SunoMediaType = _mediaPlayerStateFlow.value.nowPlayingClipData()?.mediaType ?: SunoMediaType.AUDIO override fun mediaPlayerFlow(): Flow = _mediaPlayerStateFlow.asStateFlow() override fun scrubForward() { scrubToPosition(activePlayer.currentPosition + 10_000) } override fun scrubBackward() { scrubToPosition(activePlayer.currentPosition - 10_000) } override fun scrubToZero() { scrubToPosition(0) } override fun scrubToPosition( timeStampPosition: Long, ) { if (timeStampPosition == 0L || (activePlayer.durationOrEstimate() > ZERO && activePlayer.isCurrentMediaItemSeekable) ) { activePlayer.seekTo(timeStampPosition) } } override fun scrubToPercent( percent: Float, ) { val duration = activePlayer.durationOrEstimate() val seekToTimestamp = (duration * percent.toDouble()).inWholeMilliseconds scrubToPosition(seekToTimestamp) } override fun providePlayerListenerInterceptor(): Player.Listener = object : Player.Listener { override fun onIsPlayingChanged( isPlaying: Boolean, ) { super.onIsPlayingChanged(isPlaying) _mediaPlayerStateFlow.update { oldState -> oldState.copy( isPlaying = isPlaying, ) } } override fun onPositionDiscontinuity( oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int, ) { setCurrentTimestamp(newPosition.positionMs.milliseconds, activePlayer.durationOrEstimate()) } override fun onMediaItemTransition( mediaItem: MediaItem?, reason: Int, ) { super.onMediaItemTransition(mediaItem, reason) activePlayer.currentMediaItem?.let { _mediaPlayerStateFlow.update { oldState -> oldState.copy( nowPlayingClipIndex = activePlayer.currentMediaItemIndex, ) } setCurrentTimestamp(Duration.ZERO, activePlayer.durationOrEstimate()) } } override fun onEvents( player: Player, events: Player.Events, ) { super.onEvents(player, events) if (events.contains(Player.EVENT_POSITION_DISCONTINUITY) || events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ) { val newIndex = player.currentMediaItemIndex _mediaPlayerStateFlow.update { state -> state.copy( nowPlayingClipIndex = newIndex, ) } } if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { when (player.playbackState) { Player.STATE_ENDED -> { player.seekToNext() } Player.STATE_READY -> { setIsPlaying(player.isPlaying) } Player.STATE_BUFFERING -> { } Player.STATE_IDLE -> { } } } } } // ALL MEDIA PLAYBACK SHOULD BE FUNNELED THROUGH HERE override fun setCurrentlyPlayingPlaylist( songList: List, chosenSong: LocalClipData, enablePlayback: Boolean?, ) { val intent = Intent(context, PlaybackMediaLibraryService::class.java) context.startService(intent) logger.println { "CHOSEN SONG: $chosenSong" } val mediaIndex = songList.indexOf(chosenSong) val mediaItems = songList.map { clipData -> val metadata = MediaMetadata.Builder() .setIsBrowsable(true) .setIsPlayable(true) .setArtist(clipData.artistName) .setTitle( if (clipData.nowPlayingTitle.isNullOrEmpty()) { context.resources.getString(R.string.untitled) } else { clipData.nowPlayingTitle }, ) .setArtworkUri(clipData.albumImageUrl?.toUri()) .setExtras( Bundle().apply { putString( "artistUserId", clipData.artistUserId?.value, ) }, ) .build() MediaItem.Builder() .setMediaId(clipData.clipId.value) .setUri(clipData.mediaUrl.url) .setMimeType(MimeTypes.AUDIO_MPEG) .setMediaMetadata(metadata) .build() } _mediaPlayerStateFlow.value.apply { // escape if it's the same data as before if (localClipDataQueue == songList && nowPlayingClipIndex == mediaIndex) { if (enablePlayback != null) { setIsPlaying(enablePlayback) } return } } _mediaPlayerStateFlow.update { oldPlayerState -> // always turn shuffle off when a new playlist is loaded activePlayer.shuffleModeEnabled = false oldPlayerState.copy( originalLocalClipList = songList, // preserve original list+order in memory for toggling shuffle on/off localClipDataQueue = songList, nowPlayingClipIndex = mediaIndex, startedPlayingMusic = enablePlayback ?: oldPlayerState.startedPlayingMusic, isPlaying = enablePlayback ?: oldPlayerState.isPlaying, isShuffling = false, ) } activePlayer.setMediaItems(mediaItems, mediaIndex, 0) activePlayer.prepare() if (enablePlayback == true) { activePlayer.play() } else if (enablePlayback == false) { activePlayer.pause() } } override fun stopPlaybackAndClearPlaylist() { _mediaPlayerStateFlow.update { _ -> MediaPlayerState() } activePlayer.stop() activePlayer.clearMediaItems() } override fun hasPlayedAnyClip(): Boolean = _mediaPlayerStateFlow.value.startedPlayingMusic override fun isPlaying(): Boolean = _mediaPlayerStateFlow.value.isPlaying override fun isShuffling(): Boolean = _mediaPlayerStateFlow.value.isShuffling override fun getRepeatMode(): RepeatMode = _mediaPlayerStateFlow.value.repeatMode override fun setCurrentTimestamp( timeStampPosition: Duration, duration: Duration, ) { val percentage = timeStampPosition / duration _mediaPlayerStateFlow.update { oldState -> oldState.copy( playtimeDuration = timeStampPosition, percentageComplete = percentage.toFloat(), ) } } override fun setIsPlaying( isPlaying: Boolean, ) { if (isPlaying) { activePlayer.play() } else { // is Paused activePlayer.pause() } } override fun setIsShuffling( isShuffling: Boolean, ) { activePlayer.shuffleModeEnabled = isShuffling _mediaPlayerStateFlow.update { oldState -> if (isShuffling) { val currentList = oldState.localClipDataQueue val currentlyPlayingClip = currentList.getOrNull(oldState.nowPlayingClipIndex ?: -1) val listWithoutCurrentClip = currentList.filter { it.clipId != currentlyPlayingClip?.clipId } val shuffledList = listOfNotNull(currentlyPlayingClip) + listWithoutCurrentClip.shuffled() oldState.copy( localClipDataQueue = shuffledList, nowPlayingClipIndex = 0, isShuffling = true, ) } else { val oldQueue = if (oldState.isShuffling) oldState.localClipDataQueue else oldState.originalLocalClipList val oldClip = oldQueue.getOrNull(oldState.nowPlayingClipIndex ?: -1) val newClipIndex = oldState.originalLocalClipList.indexOf(oldClip).takeIf { it >= 0 } ?: 0 oldState.copy( localClipDataQueue = oldState.originalLocalClipList, nowPlayingClipIndex = newClipIndex, isShuffling = false, ) } } } override fun cycleRepeatMode() { _mediaPlayerStateFlow.update { oldState -> val prevRepeatMode = oldState.repeatMode val nextRepeatMode = when (prevRepeatMode) { RepeatMode.REPEAT_MODE_NONE -> { RepeatMode.REPEAT_MODE_ALL } RepeatMode.REPEAT_MODE_ALL -> { RepeatMode.REPEAT_MODE_ONE } RepeatMode.REPEAT_MODE_ONE -> { RepeatMode.REPEAT_MODE_NONE } } activePlayer.repeatMode = nextRepeatMode.exoPlayerRepeatMode oldState.copy( repeatMode = nextRepeatMode, ) } } override fun setCurrentlyPlayingIndex( index: Int?, ): LocalClipData? { if (index == null) { _mediaPlayerStateFlow.update { it.copy(nowPlayingClipIndex = null) } return null } val state = _mediaPlayerStateFlow.value val isValidIndex = index in state.localClipDataQueue.indices && index < activePlayer.mediaItemCount return if (state.nowPlayingClipIndex != index && isValidIndex) { _mediaPlayerStateFlow.update { it.copy(nowPlayingClipIndex = index) } activePlayer.seekTo(index, 0) state.localClipDataQueue[index] } else { null } } override fun getCurrentlyPlayingIndex(): Int? = _mediaPlayerStateFlow.value.nowPlayingClipIndex override fun moveToNextSong() { val oldIndex = _mediaPlayerStateFlow.value.nowPlayingClipIndex ?: return // no song is playing val newIndex = if (oldIndex < _mediaPlayerStateFlow.value.localClipDataQueue.size - 1) { oldIndex + 1 } else { 0 } setCurrentlyPlayingIndex(newIndex) } override fun handleGoToPrevious() { // if playback has been more than 3 seconds, restart the track. if not, go to previous track. val currentPlaybackTime = activePlayer.currentPosition.milliseconds if (currentPlaybackTime > 3.toDuration(DurationUnit.SECONDS)) { activePlayer.seekTo(0L) return } else { val oldIndex = _mediaPlayerStateFlow.value.nowPlayingClipIndex ?: return // no song is playing val newIndex = (oldIndex - 1).coerceAtLeast(0) setCurrentlyPlayingIndex(newIndex) } } override suspend fun loadFullPlaylistById( playlistId: String, ): ApiResult = playlistService.getPlaylistById(playlistId) override fun setSinglePlayingClipData( localClipData: LocalClipData, enablePlayback: Boolean?, ) { val singleClipQueue = listOf(localClipData) setCurrentlyPlayingPlaylist( songList = singleClipQueue, chosenSong = singleClipQueue.first(), enablePlayback = enablePlayback, ) } override fun updateClip( oldLocalClipData: LocalClipData, newLocalClipData: LocalClipData, ) { // Create new queue with updated clip data val oldQueue = _mediaPlayerStateFlow.value.localClipDataQueue val newQueue = oldQueue.map { clipData -> if (clipData.clipId == oldLocalClipData.clipId) newLocalClipData else clipData } _mediaPlayerStateFlow.update { oldPlayerState -> oldPlayerState.copy( localClipDataQueue = newQueue, ) } } override fun removeClipById( clipId: Id, ) { val oldQueue = _mediaPlayerStateFlow.value.localClipDataQueue val clipIndex = oldQueue.indexOfFirst { clipData -> clipData.clipId == clipId } // We don't need to modify the media player state since the clip is not in the current playlist if (clipIndex == -1) { return } val newQueue = oldQueue.filterIndexed { index, _ -> index != clipIndex } val currentIndex = when (val nowPlayingClipIndex = _mediaPlayerStateFlow.value.nowPlayingClipIndex) { null -> null clipIndex -> if (clipIndex < newQueue.size) { clipIndex } else { 0 } else -> if (clipIndex < nowPlayingClipIndex) { nowPlayingClipIndex - 1 } else { nowPlayingClipIndex } } _mediaPlayerStateFlow.update { oldPlayerState -> oldPlayerState.copy( nowPlayingClipIndex = currentIndex, localClipDataQueue = newQueue, ) } activePlayer.removeMediaItem(clipIndex) } override fun getCastManager(): CastManager = castManager } data class MediaPlayerState( val startedPlayingMusic: Boolean = false, val nowPlayingClipIndex: Int? = null, val originalLocalClipList: List = listOf(), val localClipDataQueue: List = listOf(), val isPlaying: Boolean = false, val isShuffling: Boolean = false, val repeatMode: RepeatMode = RepeatMode.REPEAT_MODE_NONE, val playtimeDuration: Duration = Duration.ZERO, val percentageComplete: Float = 0f, ) { fun nowPlayingClipData(): LocalClipData? = if (localClipDataQueue.isNotEmpty() && nowPlayingClipIndex != null && nowPlayingClipIndex in localClipDataQueue.indices ) { localClipDataQueue[nowPlayingClipIndex] } else { null } } private const val MAX_CACHE_SIZE = 5 private const val PLAYER_CHUNK_TARGET = .8 private const val TIMESTAMP_UPDATE_JOB_DELAY = 500L