package com.suno.android.media.player.pool import androidx.annotation.MainThread import androidx.annotation.VisibleForTesting import androidx.media3.common.Player import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.thread.assertMainThread import com.suno.android.media.player.PlayerFactory /** * A pool of player instances to enable efficient media playback with minimal latency. * Manages player assignment to media items and handles lifecycle to avoid expensive player creation. */ interface PlayerPool { /** * Assigns a player to the given media ID, reusing existing assignment if available. * Creates or reuses a player from the pool to minimize initialization overhead. * * @param mediaId Unique identifier for the media content * @param index The position of the media item in the feed for position-aware eviction */ @MainThread fun assignPlayer( mediaId: Id, index: Int, ): Result /** * Releases the player assignment for the given media ID, making it available for reuse. * The underlying player remains in the pool and can be reassigned to other media. * * @param mediaId The media ID to release */ @MainThread fun unassignPlayer( mediaId: Id, ) /** * Updates the current playing index for position-aware eviction. */ @MainThread fun updateCurrentlyPlayingIndex( index: Int, ) /** * Releases all players in the pool and clears all assignments. * Should be called during application shutdown or when clearing all media state. */ @MainThread fun releaseAllPlayers() /** * Sets a listener to receive notifications about pool events. * * @param listener The listener to set, or null to remove the current listener */ @MainThread fun setListener( listener: Listener?, ) /** * Listener interface for pool events and state changes. */ interface Listener { /** * Called when a player is evicted from the pool due to reassignment. * This occurs when the pool is full and a new media assignment requires * evicting the least recently used player. * * @param evictedMediaId The media ID whose player was evicted */ fun onPlayerEvicted( evictedMediaId: Id, ) } } private const val MAXIMUM_PLAYER_POOL_SIZE = 4 /** * Implementation of [PlayerPool] with a fixed-size pool and position-aware eviction policy. * Uses synchronization to ensure thread-safe access to the player pool. * * @param playerFactory Factory function to create new player instances when needed */ class PlayerPoolImpl( loggerFactory: SunoLogger.Factory, private val playerSlotFactory: PlayerSlot.Factory, private val playerFactory: PlayerFactory, ) : PlayerPool { private val logger = loggerFactory.create(this@PlayerPoolImpl) @VisibleForTesting val assignments = mutableMapOf, PlayerSlot>() private val slots = Array(MAXIMUM_PLAYER_POOL_SIZE, playerSlotFactory::create) private var listener: PlayerPool.Listener? = null private var currentlyPlayingIndex = 0 @MainThread override fun assignPlayer( mediaId: Id, index: Int, ): Result { assertMainThread() val existingAssignedPlayer = assignments[mediaId]?.player if (existingAssignedPlayer != null) { logger.d { "Assigned player exists for media id: ${mediaId.value}, returning it" } return Result.success(existingAssignedPlayer) } val nextAvailableSlot = findNextAvailableSlot(index) nextAvailableSlot.mediaId?.let { mediaIdToUnassign -> assignments.remove(mediaIdToUnassign)?.resetSlot() listener?.onPlayerEvicted(mediaIdToUnassign) logger.d { "No players available for media id: ${mediaId.value}. Evicted player, slot: $nextAvailableSlot" } } return nextAvailableSlot.assignSlot( mediaId = mediaId, mediaIndex = index, playerFactory = playerFactory, ).also { result -> if (result.isSuccess) { assignments[mediaId] = nextAvailableSlot } logger.d { "Assigned player for media id: ${mediaId.value}, next available slot: $nextAvailableSlot" } } } /** * Finds the next available slot using position-aware eviction policy. */ private fun findNextAvailableSlot( requestedIndex: Int, ): PlayerSlot { val firstUnoccupiedSlot = slots.firstOrNull { it.mediaId == null } if (firstUnoccupiedSlot != null) { return firstUnoccupiedSlot } val isScrollingDown = requestedIndex > currentlyPlayingIndex return slots.maxBy { slot -> slot.mediaIndex?.let { mediaIndex -> if (isScrollingDown) { if (mediaIndex < currentlyPlayingIndex) currentlyPlayingIndex - mediaIndex else 0 } else { if (mediaIndex > currentlyPlayingIndex) mediaIndex - currentlyPlayingIndex else 0 } } ?: Int.MIN_VALUE } } @MainThread override fun unassignPlayer( mediaId: Id, ) { assertMainThread() assignments.remove(mediaId)?.let { slot -> slot.resetSlot() logger.d { "Un-assigned player, slot: $slot" } } } @MainThread override fun updateCurrentlyPlayingIndex( index: Int, ) { assertMainThread() currentlyPlayingIndex = index } @MainThread override fun releaseAllPlayers() { assertMainThread() assignments.forEach { (_, slot) -> slot.releaseSlot() } assignments.clear() logger.d { "Released all players" } } @MainThread override fun setListener( listener: PlayerPool.Listener?, ) { assertMainThread() this.listener = listener } }