From 0dcdbf0adfd9f39c63db1a20acc3bcad19e4f38e Mon Sep 17 00:00:00 2001 From: kimvde Date: Fri, 5 Mar 2021 13:04:57 +0000 Subject: [PATCH] Add Player onAvailableCommandsChanged callback PiperOrigin-RevId: 361122259 --- .../exoplayer2/ext/cast/CastPlayer.java | 22 ++ .../exoplayer2/ext/cast/CastPlayerTest.java | 226 ++++++++++++++++-- .../google/android/exoplayer2/BasePlayer.java | 12 +- .../com/google/android/exoplayer2/Player.java | 119 ++++++++- .../android/exoplayer2/ExoPlayerImpl.java | 26 +- .../android/exoplayer2/SimpleExoPlayer.java | 6 + .../android/exoplayer2/ExoPlayerTest.java | 137 +++++++++++ .../exoplayer2/testutil/StubExoPlayer.java | 5 + 8 files changed, 519 insertions(+), 34 deletions(-) diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 1e09ed554b..6aad15370c 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -105,6 +105,7 @@ public final class CastPlayer extends BasePlayer { private CastTimeline currentTimeline; private TrackGroupArray currentTrackGroups; private TrackSelectionArray currentTrackSelection; + private Commands availableCommands; @Player.State private int playbackState; private int currentWindowIndex; private long lastReportedPositionMs; @@ -147,6 +148,7 @@ public final class CastPlayer extends BasePlayer { currentTimeline = CastTimeline.EMPTY_CAST_TIMELINE; currentTrackGroups = TrackGroupArray.EMPTY; currentTrackSelection = EMPTY_TRACK_SELECTION_ARRAY; + availableCommands = Commands.EMPTY; pendingSeekWindowIndex = C.INDEX_UNSET; pendingSeekPositionMs = C.TIME_UNSET; @@ -369,6 +371,11 @@ public final class CastPlayer extends BasePlayer { removeMediaItems(/* fromIndex= */ 0, /* toIndex= */ currentTimeline.getWindowCount()); } + @Override + public boolean isCommandAvailable(@Command int command) { + return availableCommands.contains(command); + } + @Override public void prepare() { // Do nothing. @@ -452,6 +459,7 @@ public final class CastPlayer extends BasePlayer { listeners.queueEvent( Player.EVENT_POSITION_DISCONTINUITY, listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)); + updateAvailableCommandsAndNotifyIfChanged(); } else if (pendingSeekCount == 0) { listeners.queueEvent(/* eventFlag= */ C.INDEX_UNSET, EventListener::onSeekProcessed); } @@ -645,6 +653,7 @@ public final class CastPlayer extends BasePlayer { Player.EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)); } + updateAvailableCommandsAndNotifyIfChanged(); listeners.flushEvents(); } @@ -693,6 +702,7 @@ public final class CastPlayer extends BasePlayer { timeline, /* manifest= */ null, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); listener.onTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); }); + updateAvailableCommandsAndNotifyIfChanged(); } } @@ -762,6 +772,16 @@ public final class CastPlayer extends BasePlayer { return false; } + private void updateAvailableCommandsAndNotifyIfChanged() { + Commands previousAvailableCommands = availableCommands; + availableCommands = getAvailableCommands(); + if (!availableCommands.equals(previousAvailableCommands)) { + listeners.queueEvent( + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + listener -> listener.onAvailableCommandsChanged(availableCommands)); + } + } + @Nullable private PendingResult setMediaItemsInternal( MediaQueueItem[] mediaQueueItems, @@ -819,6 +839,7 @@ public final class CastPlayer extends BasePlayer { this.repeatMode.value = repeatMode; listeners.queueEvent( Player.EVENT_REPEAT_MODE_CHANGED, listener -> listener.onRepeatModeChanged(repeatMode)); + updateAvailableCommandsAndNotifyIfChanged(); } } @@ -1003,6 +1024,7 @@ public final class CastPlayer extends BasePlayer { @Override public void onQueueStatusUpdated() { updateTimelineAndNotifyIfChanged(); + listeners.flushEvents(); } @Override diff --git a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java index 049bc89b72..4b331ce340 100644 --- a/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java +++ b/extensions/cast/src/test/java/com/google/android/exoplayer2/ext/cast/CastPlayerTest.java @@ -15,11 +15,14 @@ */ package com.google.android.exoplayer2.ext.cast; +import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM; import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -42,6 +45,7 @@ import com.google.android.gms.cast.framework.media.MediaQueue; import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.google.android.gms.common.api.PendingResult; import com.google.android.gms.common.api.ResultCallback; +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -293,7 +297,7 @@ public class CastPlayerTest { public void addMediaItems_insertAtIndex_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); String uri = "http://www.google.com/video3"; MediaItem anotherMediaItem = new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(); @@ -316,7 +320,7 @@ public class CastPlayerTest { public void moveMediaItem_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 2); @@ -328,7 +332,7 @@ public class CastPlayerTest { public void moveMediaItem_toBegin_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 0); @@ -340,7 +344,7 @@ public class CastPlayerTest { public void moveMediaItem_toEnd_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItem(/* currentIndex= */ 1, /* newIndex= */ 4); @@ -355,7 +359,7 @@ public class CastPlayerTest { public void moveMediaItems_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 3, /* newIndex= */ 1); @@ -368,7 +372,7 @@ public class CastPlayerTest { public void moveMediaItems_toBeginning_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 4, /* newIndex= */ 0); @@ -381,7 +385,7 @@ public class CastPlayerTest { public void moveMediaItems_toEnd_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 2, /* newIndex= */ 3); @@ -396,7 +400,7 @@ public class CastPlayerTest { public void moveMediaItems_noItems_doesNotCallRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 1, /* newIndex= */ 0); @@ -407,7 +411,7 @@ public class CastPlayerTest { public void moveMediaItems_noMove_doesNotCallRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 1); @@ -418,7 +422,7 @@ public class CastPlayerTest { public void removeMediaItems_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 4); @@ -429,7 +433,7 @@ public class CastPlayerTest { public void clearMediaItems_callsRemoteMediaClient() { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); castPlayer.clearMediaItems(); @@ -444,7 +448,7 @@ public class CastPlayerTest { int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 5); List mediaItems = createMediaItems(mediaQueueItemIds); - fillTimeline(mediaItems, mediaQueueItemIds); + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); Timeline currentTimeline = castPlayer.getCurrentTimeline(); for (int i = 0; i < mediaItems.size(); i++) { @@ -453,6 +457,128 @@ public class CastPlayerTest { } } + @Test + public void seekTo_otherWindow_notifiesAvailableCommandsChanged() { + when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult); + when(mockRemoteMediaClient.queueJumpToItem(anyInt(), anyLong(), eq(null))) + .thenReturn(mockPendingResult); + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + int[] mediaQueueItemIds = new int[] {1, 2, 3}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + verify(mockListener).onAvailableCommandsChanged(any()); + + castPlayer.seekTo(/* windowIndex= */ 2, /* positionMs= */ 0); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + verify(mockListener, times(2)).onAvailableCommandsChanged(any()); + + castPlayer.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + verify(mockListener, times(2)).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener, times(3)).onAvailableCommandsChanged(any()); + + castPlayer.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); + verify(mockListener, times(3)).onAvailableCommandsChanged(any()); + } + + @Test + public void addMediaItems_whenLastPlaying_notifiesAvailableCommandsChanged() { + when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult); + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + int[] mediaQueueItemIds = new int[] {1}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + + int[] mediaQueueItemIdsToAdd = new int[] {2}; + List mediaItemsToAdd = createMediaItems(mediaQueueItemIdsToAdd); + addMediaItemsAndUpdateTimeline( + /* existingMediaItems= */ mediaItems, + /* existingMediaQueueItemIds= */ mediaQueueItemIds, + mediaItemsToAdd, + mediaQueueItemIdsToAdd); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + mediaQueueItemIdsToAdd = new int[] {3}; + mediaItemsToAdd = createMediaItems(mediaQueueItemIdsToAdd); + addMediaItemsAndUpdateTimeline( + /* existingMediaItems= */ mediaItems, + /* existingMediaQueueItemIds= */ mediaQueueItemIds, + mediaItemsToAdd, + mediaQueueItemIdsToAdd); + verify(mockListener).onAvailableCommandsChanged(any()); + } + + @Test + public void removeMediaItems_followingCurrent_notifiesAvailableCommandsChanged() { + when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult); + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + MediaItem mediaItem1 = createMediaItem(/* mediaQueueItemId= */ 1); + MediaItem mediaItem2 = createMediaItem(/* mediaQueueItemId= */ 2); + MediaItem mediaItem3 = createMediaItem(/* mediaQueueItemId= */ 3); + int[] mediaQueueItemIds = new int[] {1, 2, 3}; + List mediaItems = ImmutableList.of(mediaItem1, mediaItem2, mediaItem3); + + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + removeMediaItemsAndUpdateTimeline( + /* existingMediaItems= */ mediaItems, + /* existingMediaQueueItemIds= */ mediaQueueItemIds, + /* fromIndex= */ 2, + /* toIndex= */ 3); + verify(mockListener).onAvailableCommandsChanged(any()); + + removeMediaItemsAndUpdateTimeline( + /* existingMediaItems= */ ImmutableList.of(mediaItem1, mediaItem2), + /* existingMediaQueueItemIds= */ new int[] {1, 2}, + /* fromIndex= */ 1, + /* toIndex= */ 2); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + verify(mockListener, times(2)).onAvailableCommandsChanged(any()); + } + + @Test + public void setRepeatMode_all_notifiesAvailableCommandsChanged() { + when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult); + when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), eq(null))) + .thenReturn(mockPendingResult); + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + int[] mediaQueueItemIds = new int[] {1}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + + castPlayer.setRepeatMode(Player.REPEAT_MODE_ALL); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + } + + @Test + public void setRepeatMode_one_doesNotNotifyAvailableCommandsChanged() { + when(mockRemoteMediaClient.play()).thenReturn(mockPendingResult); + when(mockRemoteMediaClient.queueSetRepeatMode(anyInt(), eq(null))) + .thenReturn(mockPendingResult); + int[] mediaQueueItemIds = new int[] {1}; + List mediaItems = createMediaItems(mediaQueueItemIds); + + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + castPlayer.setRepeatMode(Player.REPEAT_MODE_ONE); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + } + private int[] createMediaQueueItemIds(int numberOfIds) { int[] mediaQueueItemIds = new int[numberOfIds]; for (int i = 0; i < numberOfIds; i++) { @@ -462,22 +588,77 @@ public class CastPlayerTest { } private List createMediaItems(int[] mediaQueueItemIds) { - MediaItem.Builder builder = new MediaItem.Builder(); List mediaItems = new ArrayList<>(); for (int mediaQueueItemId : mediaQueueItemIds) { - MediaItem mediaItem = - builder - .setUri("http://www.google.com/video" + mediaQueueItemId) - .setMimeType(MimeTypes.APPLICATION_MPD) - .setTag(mediaQueueItemId) - .build(); - mediaItems.add(mediaItem); + mediaItems.add(createMediaItem(mediaQueueItemId)); } return mediaItems; } - private void fillTimeline(List mediaItems, int[] mediaQueueItemIds) { - Assertions.checkState(mediaItems.size() == mediaQueueItemIds.length); + private MediaItem createMediaItem(int mediaQueueItemId) { + return new MediaItem.Builder() + .setUri("http://www.google.com/video" + mediaQueueItemId) + .setMimeType(MimeTypes.APPLICATION_MPD) + .setTag(mediaQueueItemId) + .build(); + } + + private void addMediaItemsAndUpdateTimeline(List mediaItems, int[] mediaQueueItemIds) { + addMediaItemsAndUpdateTimeline( + /* existingMediaItems= */ ImmutableList.of(), + /* existingMediaQueueItemIds= */ new int[0], + /* mediaItemsToAdd= */ mediaItems, + /* mediaQueueItemIdsToAdd= */ mediaQueueItemIds); + } + + private void addMediaItemsAndUpdateTimeline( + List existingMediaItems, + int[] existingMediaQueueItemIds, + List mediaItemsToAdd, + int[] mediaQueueItemIdsToAdd) { + Assertions.checkState(existingMediaItems.size() == existingMediaQueueItemIds.length); + Assertions.checkState(mediaItemsToAdd.size() == mediaQueueItemIdsToAdd.length); + + List mediaItems = new ArrayList<>(); + mediaItems.addAll(existingMediaItems); + mediaItems.addAll(mediaItemsToAdd); + + int existingMediaItemCount = existingMediaQueueItemIds.length; + int mediaItemToAddCount = mediaQueueItemIdsToAdd.length; + int[] mediaQueueItemIds = new int[existingMediaItemCount + mediaItemToAddCount]; + System.arraycopy(existingMediaQueueItemIds, 0, mediaQueueItemIds, 0, existingMediaItemCount); + System.arraycopy( + mediaQueueItemIdsToAdd, 0, mediaQueueItemIds, existingMediaItemCount, mediaItemToAddCount); + + castPlayer.addMediaItems(mediaItemsToAdd); + updateTimeLine(mediaItems, mediaQueueItemIds); + } + + private void removeMediaItemsAndUpdateTimeline( + List existingMediaItems, + int[] existingMediaQueueItemIds, + int fromIndex, + int toIndex) { + Assertions.checkState(existingMediaItems.size() == existingMediaQueueItemIds.length); + Assertions.checkState(fromIndex >= 0); + Assertions.checkState(fromIndex < toIndex); + int existingMediaItemCount = existingMediaItems.size(); + Assertions.checkState(toIndex <= existingMediaItemCount); + + List mediaItems = new ArrayList<>(); + int[] mediaQueueItemIds = new int[existingMediaItemCount - toIndex + fromIndex]; + for (int i = 0; i < existingMediaItemCount; i++) { + if (i < fromIndex || i >= toIndex) { + mediaItems.add(existingMediaItems.get(i)); + mediaQueueItemIds[mediaItems.size() - 1] = existingMediaQueueItemIds[i]; + } + } + + castPlayer.removeMediaItems(fromIndex, toIndex); + updateTimeLine(mediaItems, mediaQueueItemIds); + } + + private void updateTimeLine(List mediaItems, int[] mediaQueueItemIds) { List queueItems = new ArrayList<>(); DefaultMediaItemConverter converter = new DefaultMediaItemConverter(); for (MediaItem mediaItem : mediaItems) { @@ -491,7 +672,6 @@ public class CastPlayerTest { when(mockMediaInfo.getStreamType()).thenReturn(MediaInfo.STREAM_TYPE_NONE); when(mockMediaStatus.getQueueItems()).thenReturn(queueItems); - castPlayer.addMediaItems(mediaItems); // Call listener to update the timeline of the player. remoteMediaClientCallback.onQueueStatusUpdated(); } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java index 9b09c0988f..dff477c1d8 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -71,14 +71,6 @@ public abstract class BasePlayer implements Player { removeMediaItems(/* fromIndex= */ index, /* toIndex= */ index + 1); } - @Override - public boolean isCommandAvailable(@Command int command) { - if (command == COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) { - return hasNext(); - } - throw new IllegalArgumentException(); - } - @Override public final void play() { setPlayWhenReady(true); @@ -262,4 +254,8 @@ public abstract class BasePlayer implements Player { @RepeatMode int repeatMode = getRepeatMode(); return repeatMode == REPEAT_MODE_ONE ? REPEAT_MODE_OFF : repeatMode; } + + protected Commands getAvailableCommands() { + return new Commands.Builder().addIf(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, hasNext()).build(); + } } diff --git a/library/common/src/main/java/com/google/android/exoplayer2/Player.java b/library/common/src/main/java/com/google/android/exoplayer2/Player.java index 4b49803460..97f987453a 100644 --- a/library/common/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/common/src/main/java/com/google/android/exoplayer2/Player.java @@ -15,8 +15,11 @@ */ package com.google.android.exoplayer2; +import static com.google.android.exoplayer2.util.Assertions.checkState; + import android.content.Context; import android.os.Looper; +import android.util.SparseBooleanArray; import android.view.Surface; import android.view.SurfaceHolder; import android.view.SurfaceView; @@ -481,6 +484,17 @@ public interface Player { @Deprecated default void onLoadingChanged(boolean isLoading) {} + /** + * Called when the value returned from {@link #isCommandAvailable(int)} changes for at least one + * {@link Command}. + * + *

{@link #onEvents(Player, Events)} will also be called to report this event along with + * other events that happen in the same {@link Looper} message queue iteration. + * + * @param availableCommands The available {@link Commands}. + */ + default void onAvailableCommandsChanged(Commands availableCommands) {} + /** * @deprecated Use {@link #onPlaybackStateChanged(int)} and {@link * #onPlayWhenReadyChanged(boolean, int)} instead. @@ -692,6 +706,105 @@ public interface Player { } } + /** + * A set of {@link Command commands}. + * + *

Instances are immutable. + */ + final class Commands { + + /** A builder for {@link Commands} instances. */ + public static final class Builder { + + private final SparseBooleanArray commandsArray; + + private boolean buildCalled; + + /** Creates a builder. */ + public Builder() { + commandsArray = new SparseBooleanArray(); + } + + /** Creates a builder with the values of the provided {@link Commands}. */ + private Builder(Commands commands) { + this.commandsArray = commands.commandsArray.clone(); + } + + /** + * Adds a {@link Command}. + * + * @param command A {@link Command}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder add(@Command int command) { + checkState(!buildCalled); + commandsArray.append(command, /* value= */ true); + return this; + } + + /** + * Adds a {@link Command} if the provided condition is true. Does nothing otherwise. + * + * @param command A {@link Command}. + * @param condition A condition. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder addIf(@Command int command, boolean condition) { + checkState(!buildCalled); + if (condition) { + commandsArray.append(command, /* value= */ true); + } + return this; + } + + /** Builds a {@link Commands} instance. */ + public Commands build() { + checkState(!buildCalled); + buildCalled = true; + return new Commands(commandsArray); + } + } + + /** An empty set of commands. */ + public static final Commands EMPTY = new Commands.Builder().build(); + + // A SparseBooleanArray is used instead of a Set to avoid auto-boxing the Command values. + private final SparseBooleanArray commandsArray; + + private Commands(SparseBooleanArray commandsArray) { + this.commandsArray = commandsArray; + } + + /** Returns a {@link Commands.Builder} initialized with the values of this instance. */ + public Builder buildUpon() { + return new Builder(this); + } + + /** Returns whether the set of commands contains the specified {@link Command}. */ + public boolean contains(@Command int command) { + return commandsArray.get(command); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof Commands)) { + return false; + } + Commands commands = (Commands) obj; + return this.commandsArray.equals(commands.commandsArray); + } + + @Override + public int hashCode() { + return commandsArray.hashCode(); + } + } + /** * Playback state. One of {@link #STATE_IDLE}, {@link #STATE_BUFFERING}, {@link #STATE_READY} or * {@link #STATE_ENDED}. @@ -881,7 +994,8 @@ public interface Player { EVENT_SHUFFLE_MODE_ENABLED_CHANGED, EVENT_PLAYER_ERROR, EVENT_POSITION_DISCONTINUITY, - EVENT_PLAYBACK_PARAMETERS_CHANGED + EVENT_PLAYBACK_PARAMETERS_CHANGED, + EVENT_AVAILABLE_COMMANDS_CHANGED }) @interface EventFlags {} /** {@link #getCurrentTimeline()} changed. */ @@ -912,6 +1026,8 @@ public interface Player { int EVENT_POSITION_DISCONTINUITY = 12; /** {@link #getPlaybackParameters()} changed. */ int EVENT_PLAYBACK_PARAMETERS_CHANGED = 13; + /** {@link #isCommandAvailable(int)} changed for at least one {@link Command}. */ + int EVENT_AVAILABLE_COMMANDS_CHANGED = 14; /** * Commands that can be executed on a {@code Player}. One of {@link @@ -1105,6 +1221,7 @@ public interface Player { * * @param command A {@link Command}. * @return Whether the {@link Command} is available. + * @see EventListener#onAvailableCommandsChanged(Commands) */ boolean isCommandAvailable(@Command int command); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 5c52e27343..d4c270c675 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -91,6 +91,7 @@ import java.util.List; private SeekParameters seekParameters; private ShuffleOrder shuffleOrder; private boolean pauseAtEndOfMediaItems; + private Commands availableCommands; // Playback information when there is no pending seek/set source operation. private PlaybackInfo playbackInfo; @@ -174,6 +175,7 @@ import java.util.List; new ExoTrackSelection[renderers.length], /* info= */ null); period = new Timeline.Period(); + availableCommands = Commands.EMPTY; maskingWindowIndex = C.INDEX_UNSET; playbackInfoUpdateHandler = clock.createHandler(applicationLooper, /* callback= */ null); playbackInfoUpdateListener = @@ -283,6 +285,11 @@ import java.util.List; listeners.remove(listener); } + @Override + public boolean isCommandAvailable(@Command int command) { + return availableCommands.contains(command); + } + @Override @State public int getPlaybackState() { @@ -573,8 +580,10 @@ import java.util.List; if (this.repeatMode != repeatMode) { this.repeatMode = repeatMode; internalPlayer.setRepeatMode(repeatMode); - listeners.sendEvent( + listeners.queueEvent( Player.EVENT_REPEAT_MODE_CHANGED, listener -> listener.onRepeatModeChanged(repeatMode)); + updateAvailableCommands(); + listeners.flushEvents(); } } @@ -588,9 +597,11 @@ import java.util.List; if (this.shuffleModeEnabled != shuffleModeEnabled) { this.shuffleModeEnabled = shuffleModeEnabled; internalPlayer.setShuffleModeEnabled(shuffleModeEnabled); - listeners.sendEvent( + listeners.queueEvent( Player.EVENT_SHUFFLE_MODE_ENABLED_CHANGED, listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled)); + updateAvailableCommands(); + listeners.flushEvents(); } } @@ -1110,6 +1121,7 @@ import java.util.List; listener -> listener.onExperimentalSleepingForOffloadChanged(newPlaybackInfo.sleepingForOffload)); } + updateAvailableCommands(); listeners.flushEvents(); } @@ -1159,6 +1171,16 @@ import java.util.List; return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); } + private void updateAvailableCommands() { + Commands previousAvailableCommands = availableCommands; + availableCommands = getAvailableCommands(); + if (!availableCommands.equals(previousAvailableCommands)) { + listeners.queueEvent( + Player.EVENT_AVAILABLE_COMMANDS_CHANGED, + listener -> listener.onAvailableCommandsChanged(availableCommands)); + } + } + private void setMediaSourcesInternal( List mediaSources, int startWindowIndex, diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 320f4825ba..24089124c0 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -1257,6 +1257,12 @@ public class SimpleExoPlayer extends BasePlayer prepare(); } + @Override + public boolean isCommandAvailable(@Command int command) { + verifyApplicationThread(); + return player.isCommandAvailable(command); + } + @Override public void prepare() { verifyApplicationThread(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java index 07388f6b5e..d1a927a32e 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java @@ -8078,6 +8078,143 @@ public final class ExoPlayerTest { exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); } + @Test + public void seekTo_otherWindow_notifiesAvailableCommandsChanged() { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSources( + ImmutableList.of(new FakeMediaSource(), new FakeMediaSource(), new FakeMediaSource())); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.seekTo(/* windowIndex= */ 2, /* positionMs= */ 0); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + verify(mockListener, times(2)).onAvailableCommandsChanged(any()); + + player.seekTo(/* windowIndex= */ 1, /* positionMs= */ 0); + verify(mockListener, times(2)).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener, times(3)).onAvailableCommandsChanged(any()); + + player.seekTo(/* windowIndex= */ 0, /* positionMs= */ 0); + verify(mockListener, times(3)).onAvailableCommandsChanged(any()); + } + + @Test + public void automaticWindowTransition_notifiesAvailableCommandsChanged() throws Exception { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSources( + ImmutableList.of(new FakeMediaSource(), new FakeMediaSource(), new FakeMediaSource())); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.prepare(); + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + verify(mockListener, times(2)).onAvailableCommandsChanged(any()); + } + + @Test + public void addMediaItems_whenLastPlaying_notifiesAvailableCommandsChanged() throws Exception { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSource(new FakeMediaSource()); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + + player.addMediaSource(new FakeMediaSource()); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.addMediaSource(new FakeMediaSource()); + verify(mockListener).onAvailableCommandsChanged(any()); + } + + @Test + public void removeMediaItems_followingCurrent_notifiesAvailableCommandsChanged() + throws Exception { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSources( + ImmutableList.of(new FakeMediaSource(), new FakeMediaSource(), new FakeMediaSource())); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.removeMediaItem(/* index= */ 2); + verify(mockListener).onAvailableCommandsChanged(any()); + + player.removeMediaItem(/* index= */ 1); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + verify(mockListener, times(2)).onAvailableCommandsChanged(any()); + } + + @Test + public void setRepeatMode_all_notifiesAvailableCommandsChanged() throws Exception { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSource(new FakeMediaSource()); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + + player.setRepeatMode(Player.REPEAT_MODE_ALL); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + verify(mockListener).onAvailableCommandsChanged(any()); + } + + @Test + public void setRepeatMode_one_doesNotNotifyAvailableCommandsChanged() throws Exception { + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + + player.addMediaSource(new FakeMediaSource()); + player.setRepeatMode(Player.REPEAT_MODE_ONE); + verify(mockListener, never()).onAvailableCommandsChanged(any()); + } + + @Test + public void setShuffleModeEnabled_notifiesAvailableCommandsChanged() throws Exception { + Player.Commands commandsWithHasNext = + new Player.Commands.Builder().add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM).build(); + Player.EventListener mockListener = mock(Player.EventListener.class); + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + player.addListener(mockListener); + MediaSource mediaSource = + new ConcatenatingMediaSource( + false, + new FakeShuffleOrder(/* length= */ 2), + new FakeMediaSource(), + new FakeMediaSource()); + + player.addMediaSource(mediaSource); + verify(mockListener).onAvailableCommandsChanged(commandsWithHasNext); + + player.setShuffleModeEnabled(true); + verify(mockListener).onAvailableCommandsChanged(Player.Commands.EMPTY); + } + @Test public void mediaSourceMaybeThrowSourceInfoRefreshError_isNotThrownUntilPlaybackReachedFailingItem() diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index 2eb464a998..478170ca95 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -237,6 +237,11 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { throw new UnsupportedOperationException(); } + @Override + public boolean isCommandAvailable(int command) { + throw new UnsupportedOperationException(); + } + @Override public void setPlayWhenReady(boolean playWhenReady) { throw new UnsupportedOperationException();