package com.doublesymmetry.trackplayer import android.os.Bundle import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.common.Timeline import com.doublesymmetry.trackplayer.models.LiveResumeBehavior import io.mockk.every import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder import org.junit.Test import org.junit.Assert.assertFalse import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class LiveEdgeControllerTest { private fun player( live: Boolean = true, hasItem: Boolean = true, defaultSeekAvailable: Boolean = true, prepareAvailable: Boolean = true, state: Int = Player.STATE_READY, playWhenReady: Boolean = false, seekable: Boolean = true, ): Player { val extras = Bundle().apply { putBoolean("isLive", live) } val item = MediaItem.Builder() .setMediaId("test") .setMediaMetadata(MediaMetadata.Builder().setExtras(extras).build()) .build() return mockk(relaxed = true) { every { currentMediaItem } returns if (hasItem) item else null every { isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) } returns defaultSeekAvailable every { isCommandAvailable(Player.COMMAND_PREPARE) } returns prepareAvailable every { playbackState } returns state every { isCurrentMediaItemSeekable } returns seekable every { this@mockk.playWhenReady } returns playWhenReady } } @Test fun `live-edge play seeks before playing`() { val player = player() LiveEdgeController.play(player, LiveResumeBehavior.LIVE_EDGE, PlaybackRoute.LOCAL) verifyOrder { player.seekToDefaultPosition() player.play() } } @Test fun `position behavior only plays`() { val player = player() LiveEdgeController.play(player, LiveResumeBehavior.POSITION, PlaybackRoute.LOCAL) verify(exactly = 0) { player.seekToDefaultPosition() } verify(exactly = 1) { player.play() } } @Test fun `non-live and missing items do not seek`() { listOf(player(live = false), player(hasItem = false)).forEach { player -> LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) verify(exactly = 0) { player.seekToDefaultPosition() } } } @Test fun `explicit seek does not play`() { val player = player() LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) verify(exactly = 0) { player.play() } } @Test fun `unavailable default position command is a no-op`() { val player = player(defaultSeekAvailable = false) assertFalse(LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL)) verify(exactly = 0) { player.seekToDefaultPosition() } verify(exactly = 0) { player.prepare() } } @Test fun `idle local player prepares only when command is available`() { val available = player(state = Player.STATE_IDLE, prepareAvailable = true) val unavailable = player(state = Player.STATE_IDLE, prepareAvailable = false) LiveEdgeController.seekToLiveEdge(available, PlaybackRoute.LOCAL) LiveEdgeController.seekToLiveEdge(unavailable, PlaybackRoute.LOCAL) verify(exactly = 1) { available.prepare() } verify(exactly = 0) { unavailable.prepare() } } @Test fun `remote route never prepares local player`() { val player = player(state = Player.STATE_IDLE, seekable = false) LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.REMOTE_CAST) verify(exactly = 1) { player.seekToDefaultPosition() } verify(exactly = 0) { player.prepare() } verify(exactly = 0) { player.replaceMediaItem(any(), any()) } } @Test fun `seek preserves playWhenReady`() { listOf(false, true).forEach { original -> val player = player(playWhenReady = original) LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) verify { player.playWhenReady = original } } } @Test fun `idle and queue changes cancel refresh while stale callbacks cannot finish a newer generation`() { fun liveItem(id: String): MediaItem { val extras = Bundle().apply { putBoolean("isLive", true) } return MediaItem.Builder() .setMediaId(id) .setMediaMetadata(MediaMetadata.Builder().setExtras(extras).build()) .build() } val queue = mutableListOf(liveItem("first"), MediaItem.Builder().setMediaId("next").build()) val listeners = mutableListOf() val player = mockk(relaxed = true) { every { currentMediaItem } answers { queue.firstOrNull() } every { currentMediaItemIndex } returns 0 every { mediaItemCount } answers { queue.size } every { getMediaItemAt(any()) } answers { queue[firstArg()] } every { isCurrentMediaItemSeekable } returns false every { isCommandAvailable(any()) } returns true every { playWhenReady } returns false every { addListener(any()) } answers { listeners.add(firstArg()) } every { removeListener(any()) } answers { listeners.remove(firstArg()) } } LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) val staleListener = listeners.single() staleListener.onPlaybackStateChanged(Player.STATE_IDLE) LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) val currentListener = listeners.single() staleListener.onPlaybackStateChanged(Player.STATE_READY) LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) verify(exactly = 2) { player.replaceMediaItem(0, any()) } queue[0] = liveItem("replacement") currentListener.onTimelineChanged(Timeline.EMPTY, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) LiveEdgeController.seekToLiveEdge(player, PlaybackRoute.LOCAL) verify(exactly = 3) { player.replaceMediaItem(0, any()) } } }