From 39f4a17ad4ac3863af22e12711247c7a87b8613e Mon Sep 17 00:00:00 2001 From: christosts Date: Tue, 17 Jan 2023 13:38:48 +0000 Subject: [PATCH] Filter what PlaybackStateCompat actions are advertised PlayerWrapper advertises PlaybackStateCompat actions to the legacy MediaSession based on the player's available commands. PiperOrigin-RevId: 502559162 --- .../media3/session/PlayerWrapper.java | 97 +- ...tateCompatActionsWithMediaSessionTest.java | 1374 +++++++++++++++++ 2 files changed, 1443 insertions(+), 28 deletions(-) create mode 100644 libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java diff --git a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java index 97a85e3ffb..bf5f2756c9 100644 --- a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java +++ b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java @@ -917,33 +917,11 @@ import java.util.List; int state = MediaUtils.convertToPlaybackStateCompatState( playerError, getPlaybackState(), getPlayWhenReady()); - long allActions = - PlaybackStateCompat.ACTION_STOP - | PlaybackStateCompat.ACTION_PAUSE - | PlaybackStateCompat.ACTION_PLAY - | PlaybackStateCompat.ACTION_REWIND - | PlaybackStateCompat.ACTION_FAST_FORWARD - | PlaybackStateCompat.ACTION_SET_RATING - | PlaybackStateCompat.ACTION_SEEK_TO - | PlaybackStateCompat.ACTION_PLAY_PAUSE - | PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID - | PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH - | PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM - | PlaybackStateCompat.ACTION_PLAY_FROM_URI - | PlaybackStateCompat.ACTION_PREPARE - | PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID - | PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH - | PlaybackStateCompat.ACTION_PREPARE_FROM_URI - | PlaybackStateCompat.ACTION_SET_REPEAT_MODE - | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE - | PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED; - if (getAvailableCommands().contains(COMMAND_SEEK_TO_PREVIOUS) - || getAvailableCommands().contains(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) { - allActions |= PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; - } - if (getAvailableCommands().contains(COMMAND_SEEK_TO_NEXT) - || getAvailableCommands().contains(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)) { - allActions |= PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + // Always advertise ACTION_SET_RATING. + long actions = PlaybackStateCompat.ACTION_SET_RATING; + Commands availableCommands = getAvailableCommands(); + for (int i = 0; i < availableCommands.size(); i++) { + actions |= convertCommandToPlaybackStateActions(availableCommands.get(i)); } long queueItemId = isCommandAvailable(COMMAND_GET_TIMELINE) @@ -964,7 +942,7 @@ import java.util.List; PlaybackStateCompat.Builder builder = new PlaybackStateCompat.Builder() .setState(state, compatPosition, sessionPlaybackSpeed, SystemClock.elapsedRealtime()) - .setActions(allActions) + .setActions(actions) .setActiveQueueItemId(queueItemId) .setBufferedPosition(compatBufferedPosition) .setExtras(extras); @@ -1127,4 +1105,67 @@ import java.util.List; private void verifyApplicationThread() { checkState(Looper.myLooper() == getApplicationLooper()); } + + @SuppressWarnings("deprecation") // Uses deprecated PlaybackStateCompat actions. + private static long convertCommandToPlaybackStateActions(@Command int command) { + switch (command) { + case Player.COMMAND_PLAY_PAUSE: + return PlaybackStateCompat.ACTION_PAUSE + | PlaybackStateCompat.ACTION_PLAY + | PlaybackStateCompat.ACTION_PLAY_PAUSE; + case Player.COMMAND_PREPARE: + return PlaybackStateCompat.ACTION_PREPARE; + case Player.COMMAND_SEEK_BACK: + return PlaybackStateCompat.ACTION_REWIND; + case Player.COMMAND_SEEK_FORWARD: + return PlaybackStateCompat.ACTION_FAST_FORWARD; + case Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM: + return PlaybackStateCompat.ACTION_SEEK_TO; + case Player.COMMAND_SEEK_TO_MEDIA_ITEM: + return PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; + case Player.COMMAND_SEEK_TO_NEXT: + case Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM: + return PlaybackStateCompat.ACTION_SKIP_TO_NEXT; + case Player.COMMAND_SEEK_TO_PREVIOUS: + case Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM: + return PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; + case Player.COMMAND_SET_MEDIA_ITEM: + return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID + | PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH + | PlaybackStateCompat.ACTION_PLAY_FROM_URI + | PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID + | PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH + | PlaybackStateCompat.ACTION_PREPARE_FROM_URI; + case Player.COMMAND_SET_REPEAT_MODE: + return PlaybackStateCompat.ACTION_SET_REPEAT_MODE; + case Player.COMMAND_SET_SPEED_AND_PITCH: + return PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED; + case Player.COMMAND_SET_SHUFFLE_MODE: + return PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE + | PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE_ENABLED; + case Player.COMMAND_STOP: + return PlaybackStateCompat.ACTION_STOP; + case Player.COMMAND_ADJUST_DEVICE_VOLUME: + case Player.COMMAND_CHANGE_MEDIA_ITEMS: + // TODO(b/227346735): Handle this through + // MediaSessionCompat.setFlags(FLAG_HANDLES_QUEUE_COMMANDS) + case Player.COMMAND_GET_AUDIO_ATTRIBUTES: + case Player.COMMAND_GET_CURRENT_MEDIA_ITEM: + case Player.COMMAND_GET_DEVICE_VOLUME: + case Player.COMMAND_GET_MEDIA_ITEMS_METADATA: + case Player.COMMAND_GET_TEXT: + case Player.COMMAND_GET_TIMELINE: + case Player.COMMAND_GET_TRACKS: + case Player.COMMAND_GET_VOLUME: + case Player.COMMAND_INVALID: + case Player.COMMAND_SEEK_TO_DEFAULT_POSITION: + case Player.COMMAND_SET_DEVICE_VOLUME: + case Player.COMMAND_SET_MEDIA_ITEMS_METADATA: + case Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS: + case Player.COMMAND_SET_VIDEO_SURFACE: + case Player.COMMAND_SET_VOLUME: + default: + return 0; + } + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java new file mode 100644 index 0000000000..7f50a47455 --- /dev/null +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest.java @@ -0,0 +1,1374 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.media3.session; + +import static androidx.media3.test.session.common.TestUtils.TIMEOUT_MS; +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.ForwardingPlayer; +import androidx.media3.common.MediaItem; +import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Player; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.ConditionVariable; +import androidx.media3.common.util.Consumer; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.test.session.common.HandlerThreadTestRule; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; +import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Tests that {@link MediaControllerCompat} receives the expected {@link + * PlaybackStateCompat.Actions} when connected to a {@link MediaSession}. + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class MediaControllerCompatPlaybackStateCompatActionsWithMediaSessionTest { + + private static final String TAG = "MCCPSActionWithMS3"; + + @Rule public final HandlerThreadTestRule threadTestRule = new HandlerThreadTestRule(TAG); + + @Test + public void playerWithCommandPlayPause_actionsPlayAndPauseAndPlayPauseAdvertised() + throws Exception { + Player player = + createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_PLAY_PAUSE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_PAUSE).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PAUSE).isNotEqualTo(0); + + CountDownLatch latch = new CountDownLatch(2); + List receivedPlayWhenReady = new ArrayList<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + receivedPlayWhenReady.add(playWhenReady); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().play(); + controllerCompat.getTransportControls().pause(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedPlayWhenReady).containsExactly(true, false).inOrder(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandPlayPause_actionsPlayAndPauseAndPlayPauseNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_PLAY_PAUSE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_PAUSE).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PAUSE).isEqualTo(0); + + AtomicInteger playWhenReadyCalled = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + playWhenReadyCalled.incrementAndGet(); + } + + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + if (playbackState == Player.STATE_ENDED) { + latch.countDown(); + } + } + }; + player.addListener(listener); + + // play() & pause() should be a no-op + controllerCompat.getTransportControls().play(); + controllerCompat.getTransportControls().pause(); + // prepare() should transition the player to STATE_ENDED + controllerCompat.getTransportControls().prepare(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(playWhenReadyCalled.get()).isEqualTo(0); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandPrepare_actionPrepareAdvertised() throws Exception { + Player player = createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_PREPARE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_PREPARE) + .isNotEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + if (playbackState == Player.STATE_ENDED) { + latch.countDown(); + } + } + }; + player.addListener(listener); + + // prepare() should transition the player to STATE_ENDED. + controllerCompat.getTransportControls().prepare(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandPrepare_actionPrepareNotAdvertised() throws Exception { + Player player = createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_PREPARE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_PREPARE) + .isEqualTo(0); + + AtomicInteger playbackStateChanges = new AtomicInteger(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlaybackStateChanged(@Player.State int playbackState) { + playbackStateChanges.incrementAndGet(); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // prepare() should be no-op + controllerCompat.getTransportControls().prepare(); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(playbackStateChanges.get()).isEqualTo(0); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSeekBack_actionRewindAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem( + MediaItem.fromUri("asset://media/wav/sample.wav"), + /* startPositionMs= */ 500); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_BACK); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_REWIND) + .isNotEqualTo(0); + + AtomicInteger discontinuityReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + discontinuityReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().rewind(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(discontinuityReason.get()).isEqualTo(Player.DISCONTINUITY_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSeekBack_actionRewindNotAdvertised() throws Exception { + Player player = + createPlayerWithExcludedCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem( + MediaItem.fromUri("asset://media/wav/sample.wav"), + /* startPositionMs= */ 500); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_BACK); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_REWIND) + .isEqualTo(0); + + AtomicBoolean receivedOnPositionDiscontinuity = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + receivedOnPositionDiscontinuity.set(true); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + latch.countDown(); + } + }; + player.addListener(listener); + + // rewind() should be no-op. + controllerCompat.getTransportControls().rewind(); + controllerCompat.getTransportControls().play(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnPositionDiscontinuity.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSeekForward_actionFastForwardAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav")); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_FORWARD); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_FAST_FORWARD) + .isNotEqualTo(0); + + AtomicInteger discontinuityReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + discontinuityReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().fastForward(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(discontinuityReason.get()).isEqualTo(Player.DISCONTINUITY_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSeekForward_actionFastForwardNotAdvertised() throws Exception { + Player player = + createPlayerWithExcludedCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav")); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_FORWARD); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_FAST_FORWARD) + .isEqualTo(0); + + AtomicBoolean receivedOnPositionDiscontinuity = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + receivedOnPositionDiscontinuity.set(true); + } + + @Override + public void onPlayWhenReadyChanged( + boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) { + latch.countDown(); + } + }; + player.addListener(listener); + + // fastForward() should be no-op + controllerCompat.getTransportControls().fastForward(); + controllerCompat.getTransportControls().play(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnPositionDiscontinuity.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSeekInCurrentMediaItem_actionSeekToAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav")); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SEEK_TO) + .isNotEqualTo(0); + + AtomicInteger discontinuityReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + discontinuityReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().seekTo(100); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(discontinuityReason.get()).isEqualTo(Player.DISCONTINUITY_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSeekInCurrentMediaItem_actionSeekToNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItem(MediaItem.fromUri("asset://media/wav/sample.wav")); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SEEK_TO) + .isEqualTo(0); + + AtomicBoolean receiovedOnPositionDiscontinuity = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPositionDiscontinuity( + Player.PositionInfo oldPosition, + Player.PositionInfo newPosition, + @Player.DiscontinuityReason int reason) { + receiovedOnPositionDiscontinuity.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // seekTo() should be no-op. + controllerCompat.getTransportControls().seekTo(100); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receiovedOnPositionDiscontinuity.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSeekToMediaItem_actionSkipToQueueItemAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav"))); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_TO_MEDIA_ITEM); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM) + .isNotEqualTo(0); + + AtomicInteger mediaItemTransitionReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItemTransitionReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().skipToNext(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(mediaItemTransitionReason.get()).isEqualTo(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSeekToMediaItem_actionSkipToQueueItemNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav"))); + createdPlayer.prepare(); + }), + Player.COMMAND_SEEK_TO_MEDIA_ITEM); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM) + .isEqualTo(0); + + AtomicBoolean receivedOnMediaItemTransition = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + receivedOnMediaItemTransition.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // skipToQueueItem() should be no-op. + controllerCompat.getTransportControls().skipToQueueItem(1); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnMediaItemTransition.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithCommandSeekToNext_withoutCommandSeeKToNextMediaItem_actionSkipToNextAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav"))); + createdPlayer.prepare(); + }), + /* availableCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_NEXT) + .build(), + /* excludedCommand= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_NEXT) + .isNotEqualTo(0); + + AtomicInteger mediaItemTransitionReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItemTransitionReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().skipToNext(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(mediaItemTransitionReason.get()).isEqualTo(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithCommandSeekToNextMediaItem_withoutCommandSeekToNext_actionSkipToNextAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav"))); + createdPlayer.prepare(); + }), + /* availableCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build(), + /* excludedCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_NEXT) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_NEXT) + .isNotEqualTo(0); + + AtomicInteger mediaItemTransitionReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItemTransitionReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().skipToNext(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(mediaItemTransitionReason.get()).isEqualTo(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithoutCommandSeekToNextAndCommandSeekToNextMediaItem_actionSkipToNextNotAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav"))); + createdPlayer.prepare(); + }), + /* availableCommands= */ Player.Commands.EMPTY, + /* excludedCommands= */ new Player.Commands.Builder() + .addAll(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, Player.COMMAND_SEEK_TO_NEXT) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_NEXT) + .isEqualTo(0); + + AtomicBoolean receivedOnMediaItemTransition = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + receivedOnMediaItemTransition.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // skipToNext() should be no-op. + controllerCompat.getTransportControls().skipToNext(); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnMediaItemTransition.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithCommandSeekToPrevious_withoutCommandSeekToPreviousMediaItem_actionSkipToPreviousAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav")), + /* startIndex= */ 1, + /* startPositionMs= */ C.TIME_UNSET); + createdPlayer.prepare(); + }), + /* availableCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_PREVIOUS) + .build(), + /* excludedCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) + .isNotEqualTo(0); + + AtomicInteger mediaItemTransitionReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItemTransitionReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().skipToPrevious(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(mediaItemTransitionReason.get()).isEqualTo(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithCommandSeekToPreviousMediaItem_withoutCommandSeekToPrevious_actionSkipToPreviousAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav")), + /* startIndex= */ 1, + /* startPositionMs= */ C.TIME_UNSET); + createdPlayer.prepare(); + }), + /* availableCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build(), + /* excludedCommands= */ new Player.Commands.Builder() + .add(Player.COMMAND_SEEK_TO_PREVIOUS) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) + .isNotEqualTo(0); + + AtomicInteger mediaItemTransitionReason = new AtomicInteger(-1); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItemTransitionReason.set(reason); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().skipToPrevious(); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(mediaItemTransitionReason.get()).isEqualTo(Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void + playerWithoutCommandSeekToPreviousAndCommandSeekToPreviousMediaItem_actionSkipToPreviousNotAdvertised() + throws Exception { + Player player = + createPlayerWithCommands( + createPlayer( + /* onPostCreationTask= */ createdPlayer -> { + createdPlayer.setMediaItems( + ImmutableList.of( + MediaItem.fromUri("asset://media/wav/sample.wav"), + MediaItem.fromUri("asset://media/wav/sample_rf64.wav")), + /* startIndex= */ 1, + /* startPositionMs= */ C.TIME_UNSET); + createdPlayer.prepare(); + }), + /* availableCommands= */ Player.Commands.EMPTY, + /* excludedCommands= */ new Player.Commands.Builder() + .addAll(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + .build()); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) + .isEqualTo(0); + + AtomicBoolean receivedOnMediaItemTransition = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + receivedOnMediaItemTransition.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // skipToPrevious() should be no-op. + controllerCompat.getTransportControls().skipToPrevious(); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnMediaItemTransition.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSetMediaItem_actionsPlayFromXAndPrepareFromXAdvertised() + throws Exception { + Player player = + createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_SET_MEDIA_ITEM); + MediaSession mediaSession = + createMediaSession( + player, + new MediaSession.Callback() { + @Override + public ListenableFuture> onAddMediaItems( + MediaSession mediaSession, + MediaSession.ControllerInfo controller, + List mediaItems) { + return Futures.immediateFuture( + ImmutableList.of(MediaItem.fromUri("asset://media/wav/sample.wav"))); + } + }); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_URI).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_URI).isNotEqualTo(0); + + ConditionVariable conditionVariable = new ConditionVariable(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + conditionVariable.open(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().playFromMediaId(/* mediaId= */ "mediaId", Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + conditionVariable.close(); + controllerCompat + .getTransportControls() + .playFromUri(Uri.parse("https://example.invalid"), Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + conditionVariable.close(); + controllerCompat.getTransportControls().playFromSearch(/* query= */ "search", Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + conditionVariable.close(); + controllerCompat + .getTransportControls() + .prepareFromMediaId(/* mediaId= */ "mediaId", Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + conditionVariable.close(); + controllerCompat + .getTransportControls() + .prepareFromUri(Uri.parse("https://example.invalid"), Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + conditionVariable.close(); + controllerCompat.getTransportControls().prepareFromSearch(/* query= */ "search", Bundle.EMPTY); + assertThat(conditionVariable.block(TIMEOUT_MS)).isTrue(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSetMediaItem_actionsPlayFromXAndPrepareFromXNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_SET_MEDIA_ITEM); + MediaSession mediaSession = + createMediaSession( + player, + new MediaSession.Callback() { + @Override + public ListenableFuture> onAddMediaItems( + MediaSession mediaSession, + MediaSession.ControllerInfo controller, + List mediaItems) { + return Futures.immediateFuture( + ImmutableList.of(MediaItem.fromUri("asset://media/wav/sample.wav"))); + } + }); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PLAY_FROM_URI).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_PREPARE_FROM_URI).isEqualTo(0); + + AtomicBoolean receivedOnTimelineChanged = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + receivedOnTimelineChanged.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // prepareFrom and playFrom methods should be no-op. + MediaControllerCompat.TransportControls transportControls = + controllerCompat.getTransportControls(); + transportControls.prepareFromMediaId(/* mediaId= */ "mediaId", Bundle.EMPTY); + transportControls.prepareFromSearch(/* query= */ "search", Bundle.EMPTY); + transportControls.prepareFromUri(Uri.parse("https://example.invalid"), Bundle.EMPTY); + transportControls.playFromMediaId(/* mediaId= */ "mediaId", Bundle.EMPTY); + transportControls.playFromSearch(/* query= */ "search", Bundle.EMPTY); + transportControls.playFromUri(Uri.parse("https://example.invalid"), Bundle.EMPTY); + transportControls.setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedOnTimelineChanged.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSetRepeatMode_actionSetRepeatModeAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_SET_REPEAT_MODE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SET_REPEAT_MODE) + .isNotEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onRepeatModeChanged(int repeatMode) { + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().setRepeatMode(PlaybackStateCompat.REPEAT_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSetRepeatMode_actionSetRepeatModeNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_SET_REPEAT_MODE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SET_REPEAT_MODE) + .isEqualTo(0); + + AtomicBoolean repeatModeChanged = new AtomicBoolean(); + CountDownLatch latch = new CountDownLatch(1); + Player.Listener listener = + new Player.Listener() { + @Override + public void onRepeatModeChanged(int repeatMode) { + repeatModeChanged.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // setRepeatMode() should be no-op + controllerCompat.getTransportControls().setRepeatMode(PlaybackStateCompat.REPEAT_MODE_ALL); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(repeatModeChanged.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSetSpeedAndPitch_actionSetPlaybackSpeedAdvertised() + throws Exception { + Player player = + createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_SET_SPEED_AND_PITCH); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED) + .isNotEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference playbackParametersRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + playbackParametersRef.set(playbackParameters); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().setPlaybackSpeed(0.5f); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(playbackParametersRef.get().speed).isEqualTo(0.5f); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSetSpeedAndPitch_actionSetPlaybackSpeedNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_SET_SPEED_AND_PITCH); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + assertThat( + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions() + & PlaybackStateCompat.ACTION_SET_PLAYBACK_SPEED) + .isEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean receivedPlaybackParameters = new AtomicBoolean(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + receivedPlaybackParameters.set(true); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + latch.countDown(); + } + }; + player.addListener(listener); + + // setPlaybackSpeed() should be no-op. + controllerCompat.getTransportControls().setPlaybackSpeed(0.5f); + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedPlaybackParameters.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithCommandSetShuffleMode_actionSetShuffleModeAdvertised() throws Exception { + Player player = + createPlayerWithAvailableCommand(createDefaultPlayer(), Player.COMMAND_SET_SHUFFLE_MODE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE).isNotEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE_ENABLED).isNotEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean receivedShuffleModeEnabled = new AtomicBoolean(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + receivedShuffleModeEnabled.set(shuffleModeEnabled); + latch.countDown(); + } + }; + player.addListener(listener); + + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedShuffleModeEnabled.get()).isTrue(); + + mediaSession.release(); + releasePlayer(player); + } + + @Test + public void playerWithoutCommandSetShuffleMode_actionSetShuffleModeNotAdvertised() + throws Exception { + Player player = + createPlayerWithExcludedCommand(createDefaultPlayer(), Player.COMMAND_SET_SHUFFLE_MODE); + MediaSession mediaSession = createMediaSession(player); + MediaControllerCompat controllerCompat = createMediaControllerCompat(mediaSession); + + long actions = + getFirstPlaybackState(controllerCompat, threadTestRule.getHandler()).getActions(); + + assertThat(actions & PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE).isEqualTo(0); + assertThat(actions & PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE_ENABLED).isEqualTo(0); + + CountDownLatch latch = new CountDownLatch(1); + AtomicBoolean receivedShuffleModeEnabled = new AtomicBoolean(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + receivedShuffleModeEnabled.set(shuffleModeEnabled); + } + + @Override + public void onRepeatModeChanged(int repeatMode) { + latch.countDown(); + } + }; + player.addListener(listener); + + // setShuffleMode() should be no-op + controllerCompat.getTransportControls().setShuffleMode(PlaybackStateCompat.SHUFFLE_MODE_ALL); + controllerCompat.getTransportControls().setRepeatMode(PlaybackStateCompat.REPEAT_MODE_ALL); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(receivedShuffleModeEnabled.get()).isFalse(); + + mediaSession.release(); + releasePlayer(player); + } + + private PlaybackStateCompat getFirstPlaybackState( + MediaControllerCompat mediaControllerCompat, Handler handler) throws InterruptedException { + LinkedBlockingDeque playbackStateCompats = new LinkedBlockingDeque<>(); + MediaControllerCompat.Callback callback = + new MediaControllerCompat.Callback() { + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + playbackStateCompats.add(state); + } + }; + mediaControllerCompat.registerCallback(callback, handler); + PlaybackStateCompat playbackStateCompat = playbackStateCompats.take(); + mediaControllerCompat.unregisterCallback(callback); + return playbackStateCompat; + } + + /** + * Creates a default {@link ExoPlayer} instance on the main thread. Use {@link + * #releasePlayer(Player)} to release the returned instance on the main thread. + */ + private static Player createDefaultPlayer() { + return createPlayer(/* onPostCreationTask= */ player -> {}); + } + + /** + * Creates a player on the main thread. After the player is created, {@code onPostCreationTask} is + * called from the main thread to set any initial state on the player. + */ + private static Player createPlayer(Consumer onPostCreationTask) { + AtomicReference playerRef = new AtomicReference<>(); + getInstrumentation() + .runOnMainSync( + () -> { + ExoPlayer exoPlayer = + new ExoPlayer.Builder(ApplicationProvider.getApplicationContext()).build(); + onPostCreationTask.accept(exoPlayer); + playerRef.set(exoPlayer); + }); + return playerRef.get(); + } + + private static MediaSession createMediaSession(Player player) { + return createMediaSession(player, null); + } + + private static MediaSession createMediaSession( + Player player, @Nullable MediaSession.Callback callback) { + MediaSession.Builder session = + new MediaSession.Builder(ApplicationProvider.getApplicationContext(), player); + if (callback != null) { + session.setCallback(callback); + } + return session.build(); + } + + private static MediaControllerCompat createMediaControllerCompat(MediaSession mediaSession) { + return new MediaControllerCompat( + ApplicationProvider.getApplicationContext(), + mediaSession.getSessionCompat().getSessionToken()); + } + + /** Releases the {@code player} on the main thread. */ + private static void releasePlayer(Player player) { + getInstrumentation().runOnMainSync(player::release); + } + + /** + * Returns an {@link Player} where {@code availableCommand} is always included in the {@linkplain + * Player#getAvailableCommands() available commands}. + */ + private static Player createPlayerWithAvailableCommand( + Player player, @Player.Command int availableCommand) { + return createPlayerWithCommands( + player, new Player.Commands.Builder().add(availableCommand).build(), Player.Commands.EMPTY); + } + + /** + * Returns a {@link Player} where {@code excludedCommand} is always excluded from the {@linkplain + * Player#getAvailableCommands() available commands}. + */ + private static Player createPlayerWithExcludedCommand( + Player player, @Player.Command int excludedCommand) { + return createPlayerWithCommands( + player, Player.Commands.EMPTY, new Player.Commands.Builder().add(excludedCommand).build()); + } + + /** + * Returns an {@link Player} where {@code availableCommands} are always included and {@code + * excludedCommands} are always excluded from the {@linkplain Player#getAvailableCommands() + * available commands}. + */ + private static Player createPlayerWithCommands( + Player player, Player.Commands availableCommands, Player.Commands excludedCommands) { + return new ForwardingPlayer(player) { + @Override + public Commands getAvailableCommands() { + Commands.Builder commands = + super.getAvailableCommands().buildUpon().addAll(availableCommands); + for (int i = 0; i < excludedCommands.size(); i++) { + commands.remove(excludedCommands.get(i)); + } + return commands.build(); + } + + @Override + public boolean isCommandAvailable(int command) { + return getAvailableCommands().contains(command); + } + }; + } +}