package com.doublesymmetry.trackplayer import android.os.Bundle import androidx.media3.cast.CastPlayer import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player import androidx.media3.session.MediaSession import androidx.media3.session.SessionCommand import com.doublesymmetry.trackplayer.models.LiveResumeBehavior import com.doublesymmetry.trackplayer.models.PlayerCommand import com.doublesymmetry.trackplayer.models.PlayerConfig import com.doublesymmetry.trackplayer.models.RemoteControlHandling import io.mockk.every import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder import org.junit.Test import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [33]) class TrackPlayerPlaybackServiceTest { private fun service() = Robolectric.buildService(TrackPlayerPlaybackService::class.java).get() private fun livePlayer(): Player { val extras = Bundle().apply { putBoolean("isLive", true) } val item = MediaItem.Builder() .setMediaMetadata(MediaMetadata.Builder().setExtras(extras).build()) .build() return mockk(relaxed = true) { every { currentMediaItem } returns item every { isCommandAvailable(Player.COMMAND_SEEK_TO_DEFAULT_POSITION) } returns true every { isCurrentMediaItemSeekable } returns true every { playbackState } returns Player.STATE_READY every { playWhenReady } returns false } } @Test fun `forwarded play applies persisted native live-edge policy`() { val service = service() val player = livePlayer() service.playerConfig = PlayerConfig(liveResumeBehavior = LiveResumeBehavior.LIVE_EDGE) service.createForwardingPlayer(player, service.playerConfig!!).play() verifyOrder { player.seekToDefaultPosition(); player.play() } } @Test fun `forwarded play in JS mode emits without native playback`() { val service = service() val player = livePlayer() service.playerConfig = PlayerConfig(remoteControlHandling = RemoteControlHandling.JS) service.createForwardingPlayer(player, service.playerConfig!!).play() verify(exactly = 0) { player.seekToDefaultPosition() } verify(exactly = 0) { player.play() } } @Test fun `hybrid play keeps per-command native and JS routing`() { val nativeService = service() val nativePlayer = livePlayer() nativeService.playerConfig = PlayerConfig( remoteControlHandling = RemoteControlHandling.HYBRID, perCommandHandling = mapOf(PlayerCommand.PLAY_PAUSE.value to RemoteControlHandling.NATIVE), ) nativeService.createForwardingPlayer(nativePlayer, nativeService.playerConfig!!).play() verify(exactly = 1) { nativePlayer.play() } val jsService = service() val jsPlayer = livePlayer() jsService.playerConfig = PlayerConfig( remoteControlHandling = RemoteControlHandling.HYBRID, perCommandHandling = mapOf(PlayerCommand.PLAY_PAUSE.value to RemoteControlHandling.JS), ) jsService.createForwardingPlayer(jsPlayer, jsService.playerConfig!!).play() verify(exactly = 0) { jsPlayer.play() } } @Test fun `custom play reaches the same policy dispatcher`() { val service = service() val player = livePlayer() service.playerConfig = PlayerConfig(liveResumeBehavior = LiveResumeBehavior.LIVE_EDGE) service.activePlayer = player service.onCustomCommand( mockk(relaxed = true), mockk(relaxed = true), SessionCommand(TrackPlayerPlaybackService.COMMAND_PLAY, Bundle.EMPTY), Bundle.EMPTY, ) verifyOrder { player.seekToDefaultPosition(); player.play() } } @Test fun `live edge command is advertised and dispatched without playing`() { val service = service() val player = livePlayer() service.activePlayer = player val command = SessionCommand(TrackPlayerPlaybackService.COMMAND_SEEK_TO_LIVE_EDGE, Bundle.EMPTY) assertTrue(service.buildSessionCommands().contains(command)) service.onCustomCommand(mockk(relaxed = true), mockk(relaxed = true), command, Bundle.EMPTY) verify(exactly = 1) { player.seekToDefaultPosition() } verify(exactly = 0) { player.play() } } @Test fun `active cast play uses remote route without local prepare`() { val service = service() val player = livePlayer().also { every { it.playbackState } returns Player.STATE_IDLE every { it.isCommandAvailable(Player.COMMAND_PREPARE) } returns true } val castPlayer = mockk(relaxed = true) { every { isCastSessionAvailable() } returns true } service.castPlayer = castPlayer service.playerConfig = PlayerConfig(liveResumeBehavior = LiveResumeBehavior.LIVE_EDGE) assertEquals(PlaybackRoute.REMOTE_CAST, service.currentPlaybackRoute()) service.handlePlay(player) verify(exactly = 1) { player.seekToDefaultPosition() } verify(exactly = 1) { player.play() } verify(exactly = 0) { player.prepare() } } }