@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) package link.roadio.riff.player.playback import android.os.SystemClock import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.SilenceMediaSource import androidx.test.platform.app.InstrumentationRegistry import link.roadio.riff.indexer.core.media.MediaId import link.roadio.riff.player.library.PlaybackCatalogTrack import link.roadio.riff.player.library.PlayerLibraryProjection import link.roadio.riff.player.library.PlayerPlaybackCatalog import link.roadio.riff.player.storage.PlaybackCheckpoint import link.roadio.riff.player.storage.PlaybackQueueKind import link.roadio.riff.player.storage.PlaybackQueueOccurrence import link.roadio.riff.player.storage.PlaybackQueueSnapshot import link.roadio.riff.ui.BrowserItem import link.roadio.riff.ui.BrowserKind import link.roadio.riff.userstate.BoundedReadList import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.atomic.AtomicInteger class QueueMediaSourceInstrumentedTest { @Test fun emptyQueueExposesAnEmptyTimelineWithoutResolvingAnOccurrence() { val instrumentation = InstrumentationRegistry.getInstrumentation() val queue = PlaybackQueueSnapshot(42, PlaybackQueueKind.MANUAL, 1, true, emptyList(), PlaybackCheckpoint(null)) val prepared = PreparedQueue(QueueExecutionPlan(queue, emptyMap()), emptyList(), emptyList(), 0, intArrayOf()) instrumentation.runOnMainSync { val source = QueueMediaSource(prepared, DefaultMediaSourceFactory(instrumentation.targetContext)) assertTrue(source.initialTimeline.isEmpty) val player = ExoPlayer.Builder(instrumentation.targetContext).build() try { player.setMediaSource(source) player.prepare() } finally { player.release() } } } @Test fun largeQueuePreparesOnlyRequestedSourcesAndPreservesSeekShuffleAndMetadata() { val instrumentation = InstrumentationRegistry.getInstrumentation() val lookups = AtomicInteger() val created = AtomicInteger() val catalog = object : PlayerPlaybackCatalog { override val generation = 1L override val projection = PlayerLibraryProjection() override fun lookup( mediaId: MediaId, contextKey: String?, ordinal: Int, ): PlaybackCatalogTrack { lookups.incrementAndGet() return PlaybackCatalogTrack( BrowserItem("track:${mediaId.high}", "Track ${mediaId.high}", BrowserKind.TRACK, mediaId = mediaId), emptyList(), emptyMap(), ) } } val count = 200_000 val queue = PlaybackQueueSnapshot( 42, PlaybackQueueKind.MANUAL, 1, true, BoundedReadList(count) { PlaybackQueueOccurrence(it.toLong(), it, count - 1 - it, MediaId(it.toLong())) }, PlaybackCheckpoint(100_000, positionMs = 50), ) val prepared = prepareQueue(queue, catalog) assertEquals(0, lookups.get()) val factory = object : MediaSource.Factory by DefaultMediaSourceFactory(instrumentation.targetContext) { override fun createMediaSource(mediaItem: MediaItem): MediaSource { created.incrementAndGet() return SilenceMediaSource(300_000_000) } } val source = QueueMediaSource(prepared, factory) lateinit var player: ExoPlayer instrumentation.runOnMainSync { player = ExoPlayer.Builder(instrumentation.targetContext).build() player.setMediaSources(listOf(source), prepared.startIndex, 50) player.prepare() } fun ready(index: Int) { val deadline = SystemClock.elapsedRealtime() + 10_000 var done = false while (!done && SystemClock.elapsedRealtime() < deadline) { instrumentation.runOnMainSync { player.playerError?.let { throw AssertionError(it) } done = player.playbackState == Player.STATE_READY && player.currentMediaItemIndex == index } if (!done) SystemClock.sleep(10) } var diagnostic = "" instrumentation.runOnMainSync { diagnostic = "state=${player.playbackState}, index=${player.currentMediaItemIndex}, " + "position=${player.currentPosition}, buffered=${player.bufferedPosition}, " + "duration=${player.duration}, loading=${player.isLoading}, " + "created=${created.get()}, lookups=${lookups.get()}" } assertTrue("Ready at occurrence $index: $diagnostic", done) } try { ready(100_000) instrumentation.runOnMainSync { assertEquals(count, player.mediaItemCount) assertEquals("Track 100000", player.currentMediaItem?.mediaMetadata?.title) player.seekTo(count - 1, 20) } ready(count - 1) instrumentation.runOnMainSync { player.shuffleModeEnabled = true assertEquals(count - 2, player.nextMediaItemIndex) player.seekToNextMediaItem() } ready(count - 2) instrumentation.runOnMainSync { source.updateBinding( count - 2, checkNotNull(player.currentMediaItem) .buildUpon() .setMediaMetadata( player.mediaMetadata .buildUpon() .setTitle("Bound source") .build(), ).build(), ) } val deadline = SystemClock.elapsedRealtime() + 2000 var updated = false while (!updated && SystemClock.elapsedRealtime() < deadline) { instrumentation.runOnMainSync { updated = player.mediaMetadata.title == "Bound source" } if (!updated) SystemClock.sleep(10) } assertTrue(updated) assertTrue("No eager source expansion: ${created.get()}", created.get() <= 300) assertTrue("No eager metadata expansion: ${lookups.get()}", lookups.get() <= 310) } finally { instrumentation.runOnMainSync { player.release() } } } }