@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) package link.roadio.riff.player.playback import android.net.Uri import android.os.SystemClock import androidx.media3.common.Player import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.test.platform.app.InstrumentationRegistry import link.roadio.cuetree.FlatCatalogs import link.roadio.cuetree.Life import link.roadio.cuetree.MediaId import link.roadio.cuetree.Node import link.roadio.cuetree.NodeKind import link.roadio.cuetree.ROOT_IDENTITY_ATTRIBUTE import link.roadio.cuetree.Snapshot import link.roadio.cuetree.SourceInfo import link.roadio.cuetree.Span import link.roadio.riff.player.library.CuetreeBrowserTree import link.roadio.riff.player.library.PlayerLibraryProjection 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 org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import java.io.File import java.nio.ByteBuffer import java.nio.ByteOrder class FragmentRenderedPlaybackInstrumentedTest { @Test fun mappedFragmentsKeepRelativeDurationSeekAndNaturalTransitionInTheLazyQueue() = verifyClips(true) @Test fun standardMedia3QueueUsesTheSameFragmentCoordinates() = verifyClips(false) private fun verifyClips(lazyQueue: Boolean) { val instrumentation = InstrumentationRegistry.getInstrumentation() val context = instrumentation.targetContext val wav = File.createTempFile("filf-playback", ".wav", context.cacheDir) val size = 8 * 8000 * 2 val bytes = ByteBuffer.allocate(44 + size).order(ByteOrder.LITTLE_ENDIAN) bytes .put("RIFF".toByteArray()) .putInt(36 + size) .put("WAVEfmt ".toByteArray()) .putInt(16) .putShort(1) .putShort(1) .putInt(8000) .putInt(16000) .putShort(2) .putShort(16) .put("data".toByteArray()) .putInt(size) wav.writeBytes(bytes.array()) val root = Node( NodeKind.DIRECTORY, MediaId(1), source = SourceInfo("Music", provider = "fixture", locator = "root"), attributes = mapOf(ROOT_IDENTITY_ATTRIBUTE to listOf("root")), life = Life(1), ) val base = Node(NodeKind.MEDIA, MediaId(10), root.id, SourceInfo("audio.wav"), life = Life(1)) fun fragment( id: Long, ordinal: Int, begin: Long, end: Long, ) = Node( NodeKind.FRAGMENT, MediaId(id), base.id, span = Span(begin, end), attributes = mapOf("fragment" to listOf("$ordinal"), "totalFragments" to listOf("3"), "title" to listOf("Part $ordinal")), life = Life(1), ) val snapshot = Snapshot( 1, "test", listOf( root, base, fragment(20, 3, 6_000_000, 7_000_000), fragment(30, 1, 1_000_000, 3_000_000), fragment(40, 2, 4_000_000, 6_000_000), ), albums = listOf( link.roadio.cuetree.Album( 100, link.roadio.cuetree.AlbumKind.EXPLICIT, "Recording", listOf(MediaId(30), MediaId(40), MediaId(20)), listOf(3, 4, 2), emptyMap(), Life(1), ), ), ) val model = CuetreeBrowserTree .build(FlatCatalogs.encode(snapshot).view(), PlayerLibraryProjection(), emptySet(), hash = { it.contentHashCode().toLong() }) .music val tracks = model.root.catalog!! .tracks("category:folders", true, false) .filter { it.fragment } assertEquals(3, tracks.size) val queue = PlaybackQueueSnapshot( 42, PlaybackQueueKind.MANUAL, 1, true, tracks.mapIndexed { index, row -> PlaybackQueueOccurrence(100L + index, index, index, requireNotNull(row.mediaId)) }, PlaybackCheckpoint(100), ) val prepared = prepareQueue(queue, model) val factory = DefaultMediaSourceFactory( ResolvingDataSource.Factory(DefaultDataSource.Factory(context)) { spec -> spec.withUri(Uri.fromFile(wav)) }, ) lateinit var player: ExoPlayer instrumentation.runOnMainSync { player = ExoPlayer.Builder(context).build() player.volume = 0f if (lazyQueue) { player.setMediaSource( QueueMediaSource(prepared, factory), ) } else { player.setMediaSources(prepared.items.map(factory::createMediaSource)) } 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) } assertTrue("Fragment $index did not become ready", done) } try { for (index in listOf(0, 2, 1, 0)) { instrumentation.runOnMainSync { player.seekTo(index, 250) } ready(index) instrumentation.runOnMainSync { assertEquals(if (index == 2) 1000L else 2000L, player.duration) println("CLIP_PROBE lazy=$lazyQueue index=$index position=${player.currentPosition} duration=${player.duration}") assertEquals(250L, player.currentPosition) assertEquals("Part ${index + 1}", player.currentMediaItem?.mediaMetadata?.title) } } instrumentation.runOnMainSync { player.seekTo(0, 1900) player.play() } ready(1) instrumentation.runOnMainSync { assertTrue(player.currentPosition in 0..500) assertEquals(2000L, player.duration) } } finally { instrumentation.runOnMainSync { player.release() } wav.delete() } } }