package com.doublesymmetry.trackplayer import android.os.Bundle import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.test.utils.Action import androidx.media3.test.utils.ActionSchedule import androidx.media3.test.utils.ExoPlayerTestRunner import androidx.media3.test.utils.FakeMediaSource import androidx.media3.test.utils.FakeTimeline import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.util.concurrent.Executors import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.ConcurrentHashMap @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class LiveEdgeMedia3IntegrationTest { private lateinit var server: ServerSocket private lateinit var serverExecutor: ExecutorService private lateinit var player: ExoPlayer @Before fun setUp() { server = ServerSocket().apply { bind(InetSocketAddress("127.0.0.1", 0)) } serverExecutor = Executors.newCachedThreadPool() serverExecutor.execute { while (!server.isClosed) { try { val socket = server.accept() serverExecutor.execute { serve(socket) } } catch (_: Exception) { // Closing the fixture stops accept(). } } } player = ExoPlayer.Builder(RuntimeEnvironment.getApplication()).build() } @After fun tearDown() { player.release() server.close() serverExecutor.shutdownNow() } @Test fun `default-position seek targets the edge of seekable HLS`() { player.setMediaItem(liveItem("/live.m3u8")) player.prepare() await("HLS timeline to become live") { player.isCurrentMediaItemLive } val defaultPositionMs = player.currentTimeline .getWindow(player.currentMediaItemIndex, Timeline.Window()) .defaultPositionMs player.seekTo(0) assertTrue(LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL)) assertTrue(player.isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION)) assertTrue("HLS default position must be applied", player.currentPosition == defaultPositionMs) } @Test fun `default-position seek discards old position on progressive-style live timeline`() { val beforeSeekMs = AtomicLong(C.TIME_UNSET) val afterSeekMs = AtomicLong(C.TIME_UNSET) val timeline = FakeTimeline( FakeTimeline.TimelineWindowDefinition.Builder() .setSeekable(false) .setDynamic(true) .setLive(true) .setDurationUs(10_000_000) .setDefaultPositionUs(8_000_000) .build(), ) val source = FakeMediaSource.Builder().setTimeline(timeline).build() val seekToDefault = object : Action("LiveEdgeMedia3IntegrationTest", "seekToDefaultPosition") { override fun doActionImpl( player: ExoPlayer, trackSelector: androidx.media3.exoplayer.trackselection.DefaultTrackSelector, surface: android.view.Surface?, ) { beforeSeekMs.set(player.currentPosition) player.seekToDefaultPosition() } } val capturePosition = object : Action("LiveEdgeMedia3IntegrationTest", "capturePosition") { override fun doActionImpl( player: ExoPlayer, trackSelector: androidx.media3.exoplayer.trackselection.DefaultTrackSelector, surface: android.view.Surface?, ) { afterSeekMs.set(player.currentPosition) } } val schedule = ActionSchedule.Builder("progressive-live-default-position") .waitForPlaybackState(Player.STATE_READY) .pause() .apply(seekToDefault) .waitForPendingPlayerCommands() .apply(capturePosition) .play() .build() val runner = ExoPlayerTestRunner.Builder(RuntimeEnvironment.getApplication()) .setMediaSources(source) .initialSeek(0, 2_000) .setActionSchedule(schedule) .setExpectedPlayerEndedCount(0) .build() .start() runner.blockUntilActionScheduleFinished(10_000) runner.blockUntilEnded(10_000) assertTrue("characterization must start away from the default", beforeSeekMs.get() == 2_000L) assertTrue("default-position seek must move to the live default", afterSeekMs.get() == 8_000L) runner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_SEEK, Player.DISCONTINUITY_REASON_SEEK, ) assertTrue("the fake progressive source must create a real MediaPeriod", source.createdMediaPeriods.isNotEmpty()) } @Test fun `non-seekable ready live source is recreated once and preserves queue identity and intent`() { player.release() val creations = AtomicInteger() val creationsById = ConcurrentHashMap() val timeline = FakeTimeline( FakeTimeline.TimelineWindowDefinition.Builder() .setSeekable(false) .setDynamic(true) .setLive(true) .setDurationUs(C.TIME_UNSET) .setDefaultPositionUs(0) .build(), ) val factory = object : MediaSource.Factory { override fun setDrmSessionManagerProvider(provider: DrmSessionManagerProvider) = this override fun setLoadErrorHandlingPolicy(policy: LoadErrorHandlingPolicy) = this override fun getSupportedTypes() = intArrayOf(C.CONTENT_TYPE_OTHER) override fun createMediaSource(mediaItem: MediaItem): MediaSource = object : FakeMediaSource(timeline) { private var cacheKey = mediaItem.localConfiguration?.customCacheKey override fun canUpdateMediaItem(updatedItem: MediaItem): Boolean = updatedItem.localConfiguration?.uri == mediaItem.localConfiguration?.uri && updatedItem.localConfiguration?.customCacheKey == cacheKey override fun updateMediaItem(updatedItem: MediaItem) { cacheKey = updatedItem.localConfiguration?.customCacheKey super.updateMediaItem(updatedItem) } }.apply { updateMediaItem(mediaItem) creations.incrementAndGet() creationsById.computeIfAbsent(mediaItem.mediaId) { AtomicInteger() }.incrementAndGet() } } player = ExoPlayer.Builder(RuntimeEnvironment.getApplication()) .setMediaSourceFactory(LiveRefreshMediaSourceFactory(factory)) .build() val first = liveItem("/continuous.aac").buildUpon().setMediaId("live-id").build() val second = MediaItem.Builder().setMediaId("next-id").setUri("https://example.com/next.mp3").build() player.setMediaItems(listOf(first, second), 0, 0) player.prepare() player.playWhenReady = false await("non-seekable live item to become ready") { player.playbackState == Player.STATE_READY } val initialCreations = creations.get() val initialLiveCreations = creationsById.getValue("live-id").get() val initialNextCreations = creationsById.getValue("next-id").get() assertTrue(LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL)) assertTrue(LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL)) await("refreshed live item to become ready") { player.playbackState == Player.STATE_READY && creations.get() > initialCreations } assertTrue("refresh must recreate only the current source", creations.get() == initialCreations + 1) assertTrue( "current source holder must be recreated", creationsById.getValue("live-id").get() == initialLiveCreations + 1, ) assertTrue( "upcoming source holder must be retained", creationsById.getValue("next-id").get() == initialNextCreations, ) assertTrue("refresh discriminator must not alter the media item", player.currentMediaItem == first) assertTrue("current identity must survive refresh", player.currentMediaItem?.mediaId == "live-id") assertTrue("queue identity must survive refresh", player.getMediaItemAt(1).mediaId == "next-id") assertTrue("paused intent must survive refresh", !player.playWhenReady) assertTrue(LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL)) await("second refreshed live item to become ready") { player.playbackState == Player.STATE_READY && creationsById.getValue("live-id").get() == initialLiveCreations + 2 } assertTrue("each completed refresh must recreate the current source", creations.get() == initialCreations + 2) assertTrue("repeated refresh must still retain the upcoming holder", creationsById.getValue("next-id").get() == initialNextCreations) val metadataUpdate = player.currentMediaItem!!.buildUpon() .setMediaMetadata(player.currentMediaItem!!.mediaMetadata.buildUpon().setTitle("updated").build()) .build() player.replaceMediaItem(0, metadataUpdate) await("metadata-only update to settle") { player.currentMediaItem?.mediaMetadata?.title == "updated" } assertTrue("unmarked metadata updates must remain in-place", creations.get() == initialCreations + 2) } private fun liveItem(path: String): MediaItem { val extras = Bundle().apply { putBoolean("isLive", true) } return MediaItem.Builder() .setUri("http://127.0.0.1:${server.localPort}$path") .setMediaMetadata(MediaMetadata.Builder().setExtras(extras).build()) .build() } private fun await(description: String, condition: () -> Boolean) { val deadline = System.currentTimeMillis() + 8_000 while (!condition() && System.currentTimeMillis() < deadline) { shadowOf(android.os.Looper.getMainLooper()).idle() Thread.sleep(10) } assertTrue("Timed out waiting for $description", condition()) } private fun serve(socket: Socket) { socket.use { connection -> val reader = connection.getInputStream().bufferedReader() val path = reader.readLine()?.split(' ')?.getOrNull(1) ?: return while (!reader.readLine().isNullOrEmpty()) Unit when (path) { "/live.m3u8" -> connection.respond( "application/vnd.apple.mpegurl", """#EXTM3U |#EXT-X-VERSION:3 |#EXT-X-TARGETDURATION:1 |#EXT-X-MEDIA-SEQUENCE:1 |#EXTINF:1.0, |segment1.aac |#EXTINF:1.0, |segment2.aac |#EXTINF:1.0, |segment3.aac |""".trimMargin().toByteArray(), ) "/segment1.aac", "/segment2.aac", "/segment3.aac" -> connection.respond("audio/aac", adtsFrames(45)) else -> connection.respond("text/plain", "not found".toByteArray(), "404 Not Found") } } } private fun Socket.respond(contentType: String, bytes: ByteArray, status: String = "200 OK") { getOutputStream().use { output -> output.write( "HTTP/1.1 $status\r\nContent-Type: $contentType\r\nContent-Length: ${bytes.size}\r\nConnection: close\r\n\r\n" .toByteArray(), ) output.write(bytes) } } private fun adtsFrames(count: Int): ByteArray = ByteArray(adtsFrame().size * count).also { result -> val frame = adtsFrame() repeat(count) { frame.copyInto(result, it * frame.size) } } private fun adtsFrame(): ByteArray { val size = 100 return ByteArray(size).apply { this[0] = 0xff.toByte() this[1] = 0xf1.toByte() this[2] = 0x50 this[3] = (0x80 or (size shr 11)).toByte() this[4] = (size shr 3).toByte() this[5] = (((size and 7) shl 5) or 0x1f).toByte() this[6] = 0xfc.toByte() } } }