package com.suno.android.media.hooks.preload import androidx.annotation.OptIn import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.preload.DefaultPreloadManager import androidx.media3.exoplayer.source.preload.PreloadManagerListener import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.media.HooksFeed import com.suno.android.media.hooks.toMediaItem import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton private const val ITEMS_TO_KEEP_BEFORE_CURRENTLY_PLAYING_INDEX = 10 /** * Manages media preloading operations for the hooks feed. */ @OptIn(UnstableApi::class) @Singleton class HooksFeedPreloadManager @Inject constructor( loggerFactory: SunoLogger.Factory, @HooksFeed private val defaultPreloadManagerBuilder: DefaultPreloadManager.Builder, private val hooksFeedPreloadStatusControl: HooksFeedPreloadStatusControl, ) { private val logger = loggerFactory.create(this@HooksFeedPreloadManager) private val preloadListener = object : PreloadManagerListener { override fun onCompleted( mediaItem: MediaItem, ) { logger.d { "✅ Preloaded ${mediaItem.mediaId}" } } } private val preloadManager = defaultPreloadManagerBuilder.build().apply { addListener(preloadListener) } private val indicesToMediaItems = ConcurrentHashMap() private val nextIndex = AtomicInteger(0) fun addHooks( hooks: List, ) { logger.d { "Adding hooks, hooks count: ${hooks.size}, current media items count: ${indicesToMediaItems.size}" } removeOldMediaItems() hooks.forEach { hook -> val currentIndex = nextIndex.getAndIncrement() val mediaItem = hook.toMediaItem() preloadManager.add(mediaItem, currentIndex) indicesToMediaItems[currentIndex] = mediaItem } preloadManager.invalidate() } /** * Removes old media items, keeping [ITEMS_TO_KEEP_BEFORE_CURRENTLY_PLAYING_INDEX] items before currently * playing index. This is to prevent preload manager and media items from growing unbounded. */ private fun removeOldMediaItems() { if (indicesToMediaItems.isEmpty()) return val currentlyPlayingIndex = hooksFeedPreloadStatusControl.getCurrentlyPlayingIndex() val startIndexToKeep = (currentlyPlayingIndex - ITEMS_TO_KEEP_BEFORE_CURRENTLY_PLAYING_INDEX).coerceAtLeast(0) if (startIndexToKeep <= 0) return val indicesToRemove = indicesToMediaItems.keys.filter { index -> index < startIndexToKeep } logger.d { "Removing old media items, currently playing index: $currentlyPlayingIndex, " + "start index to keep: $startIndexToKeep, total media items: ${indicesToMediaItems.size}, " + "items to remove: ${indicesToRemove.size}" } var removedItemsCount = 0 indicesToRemove.forEach { index -> val removedMediaItem = indicesToMediaItems.remove(index) removedMediaItem?.let { mediaItem -> preloadManager.remove(mediaItem) removedItemsCount++ } } logger.d { "Removed $removedItemsCount old media items" } } fun getMediaSource( mediaItem: MediaItem, ): MediaSource? = preloadManager.getMediaSource(mediaItem) fun updateCurrentlyPlayingIndex( index: Int, ) { hooksFeedPreloadStatusControl.updateCurrentlyPlayingIndex(index) preloadManager.invalidate() } fun clear() { logger.d { "Clearing preload manager" } runCatching { preloadManager.removeListener(preloadListener) }.onFailure { logger.e(it) { "Failed to remove preload listener" } } preloadManager.reset() indicesToMediaItems.clear() nextIndex.set(0) } }