diff --git a/library/common/build.gradle b/library/common/build.gradle
index d1d0d86f42..b5aec23700 100644
--- a/library/common/build.gradle
+++ b/library/common/build.gradle
@@ -37,6 +37,7 @@ dependencies {
testImplementation 'com.google.truth:truth:' + truthVersion
testImplementation 'com.squareup.okhttp3:mockwebserver:' + mockWebServerVersion
testImplementation 'org.robolectric:robolectric:' + robolectricVersion
+ testImplementation project(modulePrefix + 'library-core')
testImplementation project(modulePrefix + 'testutils')
}
diff --git a/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java
index e7aaafd689..eb78179752 100644
--- a/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java
+++ b/library/common/src/main/java/com/google/android/exoplayer2/ForwardingPlayer.java
@@ -15,31 +15,74 @@
*/
package com.google.android.exoplayer2;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+
import android.os.Looper;
import android.view.Surface;
import android.view.SurfaceHolder;
import android.view.SurfaceView;
import android.view.TextureView;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.device.DeviceInfo;
import com.google.android.exoplayer2.metadata.Metadata;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.text.Cue;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
+import com.google.android.exoplayer2.util.Clock;
+import com.google.android.exoplayer2.util.ListenerSet;
import com.google.android.exoplayer2.video.VideoSize;
import java.util.List;
/**
* A {@link Player} that forwards operations to another {@link Player}. Applications can use this
* class to suppress or modify specific operations, by overriding the respective methods.
+ *
+ *
An application can {@link #setDisabledCommands disable available commands}. When the wrapped
+ * player advertises available commands, either with {@link Player#isCommandAvailable(int)} or with
+ * {@link Listener#onAvailableCommandsChanged}, the disabled commands will be filtered out.
*/
public class ForwardingPlayer extends BasePlayer {
private final Player player;
+ private final Clock clock;
+ @Nullable private ForwardingListener forwardingListener;
+
+ private Commands disabledCommands;
+ @Nullable private Commands unfilteredCommands;
+ @Nullable private Commands filteredCommands;
/** Creates a new instance that forwards all operations to {@code player}. */
public ForwardingPlayer(Player player) {
+ this(player, Clock.DEFAULT);
+ }
+
+ @VisibleForTesting
+ /* package */ ForwardingPlayer(Player player, Clock clock) {
this.player = player;
+ this.clock = clock;
+ this.disabledCommands = Commands.EMPTY;
+ }
+
+ /**
+ * Sets the disabled {@link Commands}.
+ *
+ *
When querying for available commands with {@link #isCommandAvailable(int)}, or when the
+ * wrapped player advertises available commands with {@link Listener#isCommandAvailable}, disabled
+ * commands will be filtered out.
+ */
+ public void setDisabledCommands(Commands commands) {
+ checkState(player.getApplicationLooper().equals(Looper.myLooper()));
+ disabledCommands = commands;
+ filteredCommands = null;
+ if (forwardingListener != null) {
+ forwardingListener.maybeAdvertiseAvailableCommands();
+ }
+ }
+
+ /** Returns the disabled commands. */
+ public Commands getDisabledCommands() {
+ return disabledCommands;
}
@Override
@@ -49,22 +92,34 @@ public class ForwardingPlayer extends BasePlayer {
@Override
public void addListener(EventListener listener) {
- player.addListener(listener);
+ addListener(new EventListenerWrapper(listener));
}
@Override
public void addListener(Listener listener) {
- player.addListener(listener);
+ if (forwardingListener == null) {
+ forwardingListener = new ForwardingListener(this);
+ }
+ if (!forwardingListener.isRegistered()) {
+ forwardingListener.registerTo(player);
+ }
+ forwardingListener.addListener(listener);
}
@Override
public void removeListener(EventListener listener) {
- player.removeListener(listener);
+ removeListener(new EventListenerWrapper(listener));
}
@Override
public void removeListener(Listener listener) {
- player.removeListener(listener);
+ if (forwardingListener == null) {
+ return;
+ }
+ forwardingListener.removeListener(listener);
+ if (!forwardingListener.hasListeners()) {
+ forwardingListener.unregisterFrom(player);
+ }
}
@Override
@@ -95,7 +150,12 @@ public class ForwardingPlayer extends BasePlayer {
@Override
public Commands getAvailableCommands() {
- return player.getAvailableCommands();
+ Commands commands = player.getAvailableCommands();
+ if (filteredCommands == null || !commands.equals(unfilteredCommands)) {
+ filteredCommands = filterCommands(commands, disabledCommands);
+ unfilteredCommands = commands;
+ }
+ return filteredCommands;
}
@Override
@@ -364,4 +424,474 @@ public class ForwardingPlayer extends BasePlayer {
public void setDeviceMuted(boolean muted) {
player.setDeviceMuted(muted);
}
+
+ /**
+ * Wraps a {@link Listener} and intercepts {@link EventListener#onAvailableCommandsChanged} in
+ * order to filter disabled commands. All other operations are forwarded to the wrapped {@link
+ * Listener}.
+ */
+ private static class ForwardingListener implements Listener {
+ private final ForwardingPlayer player;
+ private final ListenerSet listeners;
+ private boolean registered;
+ private Commands lastReceivedCommands;
+ private Commands lastAdvertisedCommands;
+
+ public ForwardingListener(ForwardingPlayer forwardingPlayer) {
+ this.player = forwardingPlayer;
+ listeners =
+ new ListenerSet<>(
+ forwardingPlayer.player.getApplicationLooper(),
+ forwardingPlayer.clock,
+ (listener, flags) -> listener.onEvents(forwardingPlayer, new Events(flags)));
+ lastReceivedCommands = Commands.EMPTY;
+ lastAdvertisedCommands = Commands.EMPTY;
+ }
+
+ public void registerTo(Player player) {
+ checkState(!registered);
+ player.addListener(this);
+ lastReceivedCommands = player.getAvailableCommands();
+ lastAdvertisedCommands = lastReceivedCommands;
+ registered = true;
+ }
+
+ public void unregisterFrom(Player player) {
+ checkState(registered);
+ player.removeListener(this);
+ registered = false;
+ }
+
+ public boolean isRegistered() {
+ return registered;
+ }
+
+ public void addListener(Listener listener) {
+ listeners.add(listener);
+ }
+
+ public void removeListener(Listener listener) {
+ listeners.remove(listener);
+ }
+
+ public boolean hasListeners() {
+ return listeners.size() > 0;
+ }
+
+ // VideoListener callbacks
+ @Override
+ public void onVideoSizeChanged(VideoSize videoSize) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onVideoSizeChanged(videoSize));
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onVideoSizeChanged(
+ int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {
+ listeners.sendEvent(
+ C.INDEX_UNSET,
+ listener ->
+ listener.onVideoSizeChanged(
+ width, height, unappliedRotationDegrees, pixelWidthHeightRatio));
+ }
+
+ @Override
+ public void onSurfaceSizeChanged(int width, int height) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onSurfaceSizeChanged(width, height));
+ }
+
+ @Override
+ public void onRenderedFirstFrame() {
+ listeners.sendEvent(C.INDEX_UNSET, Listener::onRenderedFirstFrame);
+ }
+
+ // AudioListener callbacks
+
+ @Override
+ public void onAudioSessionIdChanged(int audioSessionId) {
+ listeners.sendEvent(
+ C.INDEX_UNSET, listener -> listener.onAudioSessionIdChanged(audioSessionId));
+ }
+
+ @Override
+ public void onAudioAttributesChanged(AudioAttributes audioAttributes) {
+ listeners.sendEvent(
+ C.INDEX_UNSET, listener -> listener.onAudioAttributesChanged(audioAttributes));
+ }
+
+ @Override
+ public void onVolumeChanged(float volume) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onVolumeChanged(volume));
+ }
+
+ @Override
+ public void onSkipSilenceEnabledChanged(boolean skipSilenceEnabled) {
+ listeners.sendEvent(
+ C.INDEX_UNSET, listener -> listener.onSkipSilenceEnabledChanged(skipSilenceEnabled));
+ }
+
+ // TextOutput callbacks
+
+ @Override
+ public void onCues(List cues) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onCues(cues));
+ }
+
+ // MetadataOutput callbacks
+
+ @Override
+ public void onMetadata(Metadata metadata) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onMetadata(metadata));
+ }
+
+ // DeviceListener callbacks
+
+ @Override
+ public void onDeviceInfoChanged(DeviceInfo deviceInfo) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onDeviceInfoChanged(deviceInfo));
+ }
+
+ @Override
+ public void onDeviceVolumeChanged(int volume, boolean muted) {
+ listeners.sendEvent(C.INDEX_UNSET, listener -> listener.onDeviceVolumeChanged(volume, muted));
+ }
+
+ // EventListener callbacks
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
+ listeners.sendEvent(
+ EVENT_TIMELINE_CHANGED, listener -> listener.onTimelineChanged(timeline, reason));
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onTimelineChanged(
+ Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {
+ listeners.sendEvent(
+ EVENT_TIMELINE_CHANGED,
+ listener -> listener.onTimelineChanged(timeline, manifest, reason));
+ }
+
+ @Override
+ public void onMediaItemTransition(
+ @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {
+ listeners.sendEvent(
+ EVENT_MEDIA_ITEM_TRANSITION,
+ listener -> listener.onMediaItemTransition(mediaItem, reason));
+ }
+
+ @Override
+ public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
+ listeners.sendEvent(
+ EVENT_TRACKS_CHANGED, listener -> listener.onTracksChanged(trackGroups, trackSelections));
+ }
+
+ @Override
+ public void onStaticMetadataChanged(List metadataList) {
+ listeners.sendEvent(
+ EVENT_STATIC_METADATA_CHANGED,
+ listener -> listener.onStaticMetadataChanged(metadataList));
+ }
+
+ @Override
+ public void onMediaMetadataChanged(MediaMetadata mediaMetadata) {
+ listeners.sendEvent(
+ EVENT_MEDIA_METADATA_CHANGED, listener -> listener.onMediaMetadataChanged(mediaMetadata));
+ }
+
+ @Override
+ public void onIsLoadingChanged(boolean isLoading) {
+ listeners.sendEvent(
+ EVENT_IS_LOADING_CHANGED, listener -> listener.onIsLoadingChanged(isLoading));
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onLoadingChanged(boolean isLoading) {
+ listeners.sendEvent(
+ EVENT_IS_LOADING_CHANGED, listener -> listener.onLoadingChanged(isLoading));
+ }
+
+ @Override
+ public void onAvailableCommandsChanged(Commands availableCommands) {
+ lastReceivedCommands = availableCommands;
+ maybeAdvertiseAvailableCommands();
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) {
+ listeners.sendEvent(
+ C.INDEX_UNSET, listener -> listener.onPlayerStateChanged(playWhenReady, playbackState));
+ }
+
+ @Override
+ public void onPlaybackStateChanged(@State int state) {
+ listeners.sendEvent(
+ EVENT_PLAYBACK_STATE_CHANGED, listener -> listener.onPlaybackStateChanged(state));
+ }
+
+ @Override
+ public void onPlayWhenReadyChanged(boolean playWhenReady, @State int reason) {
+ listeners.sendEvent(
+ EVENT_PLAY_WHEN_READY_CHANGED,
+ listener -> listener.onPlayWhenReadyChanged(playWhenReady, reason));
+ }
+
+ @Override
+ public void onPlaybackSuppressionReasonChanged(
+ @PlaybackSuppressionReason int playbackSuppressionReason) {
+ listeners.sendEvent(
+ EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED,
+ listener -> listener.onPlaybackSuppressionReasonChanged(playbackSuppressionReason));
+ }
+
+ @Override
+ public void onIsPlayingChanged(boolean isPlaying) {
+ listeners.sendEvent(
+ EVENT_IS_PLAYING_CHANGED, listener -> listener.onIsPlayingChanged(isPlaying));
+ }
+
+ @Override
+ public void onRepeatModeChanged(@RepeatMode int repeatMode) {
+ listeners.sendEvent(
+ EVENT_REPEAT_MODE_CHANGED, listener -> listener.onRepeatModeChanged(repeatMode));
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
+ listeners.sendEvent(
+ EVENT_SHUFFLE_MODE_ENABLED_CHANGED,
+ listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled));
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException error) {
+ listeners.sendEvent(EVENT_PLAYER_ERROR, listener -> listener.onPlayerError(error));
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
+ listeners.sendEvent(
+ EVENT_POSITION_DISCONTINUITY, listener -> listener.onPositionDiscontinuity(reason));
+ }
+
+ @Override
+ public void onPositionDiscontinuity(
+ PositionInfo oldPosition, PositionInfo newPosition, @DiscontinuityReason int reason) {
+ listeners.sendEvent(
+ EVENT_POSITION_DISCONTINUITY,
+ listener -> listener.onPositionDiscontinuity(oldPosition, newPosition, reason));
+ }
+
+ @Override
+ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
+ listeners.sendEvent(
+ EVENT_PLAYBACK_PARAMETERS_CHANGED,
+ listener -> listener.onPlaybackParametersChanged(playbackParameters));
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onSeekProcessed() {
+ listeners.sendEvent(C.INDEX_UNSET, EventListener::onSeekProcessed);
+ }
+
+ @Override
+ public void onEvents(Player player, Events events) {
+ // Do nothing, individual callbacks will trigger this event on behalf of the forwarding
+ // player.
+ }
+
+ public void maybeAdvertiseAvailableCommands() {
+ Commands commandsToAdvertise = filterCommands(lastReceivedCommands, player.disabledCommands);
+ if (!commandsToAdvertise.equals(lastAdvertisedCommands)) {
+ lastAdvertisedCommands = commandsToAdvertise;
+ listeners.sendEvent(
+ EVENT_AVAILABLE_COMMANDS_CHANGED,
+ listener -> listener.onAvailableCommandsChanged(commandsToAdvertise));
+ }
+ }
+ }
+
+ /**
+ * Wraps an {@link EventListener} as a {@link Listener} so that it can be used by the {@link
+ * ForwardingListener}.
+ */
+ private static class EventListenerWrapper implements Listener {
+ private final EventListener listener;
+
+ /** Wraps an {@link EventListener}. */
+ public EventListenerWrapper(EventListener listener) {
+ this.listener = listener;
+ }
+
+ // EventListener callbacks
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onTimelineChanged(
+ Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {
+ listener.onTimelineChanged(timeline, manifest, reason);
+ }
+
+ @Override
+ public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) {
+ listener.onTimelineChanged(timeline, reason);
+ }
+
+ @Override
+ public void onMediaItemTransition(
+ @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {
+ listener.onMediaItemTransition(mediaItem, reason);
+ }
+
+ @Override
+ public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
+ listener.onTracksChanged(trackGroups, trackSelections);
+ }
+
+ @Override
+ public void onStaticMetadataChanged(List metadataList) {
+ listener.onStaticMetadataChanged(metadataList);
+ }
+
+ @Override
+ public void onMediaMetadataChanged(MediaMetadata mediaMetadata) {
+ listener.onMediaMetadataChanged(mediaMetadata);
+ }
+
+ @Override
+ public void onIsLoadingChanged(boolean isLoading) {
+ listener.onIsLoadingChanged(isLoading);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onLoadingChanged(boolean isLoading) {
+ listener.onLoadingChanged(isLoading);
+ }
+
+ @Override
+ public void onAvailableCommandsChanged(Commands availableCommands) {
+ listener.onAvailableCommandsChanged(availableCommands);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) {
+ listener.onPlayerStateChanged(playWhenReady, playbackState);
+ }
+
+ @Override
+ public void onPlaybackStateChanged(@State int state) {
+ listener.onPlaybackStateChanged(state);
+ }
+
+ @Override
+ public void onPlayWhenReadyChanged(
+ boolean playWhenReady, @PlayWhenReadyChangeReason int reason) {
+ listener.onPlayWhenReadyChanged(playWhenReady, reason);
+ }
+
+ @Override
+ public void onPlaybackSuppressionReasonChanged(
+ @PlaybackSuppressionReason int playbackSuppressionReason) {
+ listener.onPlaybackSuppressionReasonChanged(playbackSuppressionReason);
+ }
+
+ @Override
+ public void onIsPlayingChanged(boolean isPlaying) {
+ listener.onIsPlayingChanged(isPlaying);
+ }
+
+ @Override
+ public void onRepeatModeChanged(@RepeatMode int repeatMode) {
+ listener.onRepeatModeChanged(repeatMode);
+ }
+
+ @Override
+ public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {
+ listener.onShuffleModeEnabledChanged(shuffleModeEnabled);
+ }
+
+ @Override
+ public void onPlayerError(ExoPlaybackException error) {
+ listener.onPlayerError(error);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onPositionDiscontinuity(@DiscontinuityReason int reason) {
+ listener.onPositionDiscontinuity(reason);
+ }
+
+ @Override
+ public void onPositionDiscontinuity(
+ PositionInfo oldPosition, PositionInfo newPosition, @DiscontinuityReason int reason) {
+ listener.onPositionDiscontinuity(oldPosition, newPosition, reason);
+ }
+
+ @Override
+ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {
+ listener.onPlaybackParametersChanged(playbackParameters);
+ }
+
+ @Override
+ @SuppressWarnings("deprecation") // Forwarding to deprecated method.
+ public void onSeekProcessed() {
+ listener.onSeekProcessed();
+ }
+
+ @Override
+ public void onEvents(Player player, Events events) {
+ listener.onEvents(player, events);
+ }
+
+ // Other Listener callbacks, they should never be invoked on this wrapper.
+
+ @Override
+ public void onMetadata(Metadata metadata) {
+ throw new IllegalStateException();
+ }
+
+ @Override
+ public void onCues(List cues) {
+ throw new IllegalStateException();
+ }
+
+ @Override
+ public boolean equals(@Nullable Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (!(o instanceof EventListenerWrapper)) {
+ return false;
+ }
+
+ EventListenerWrapper that = (EventListenerWrapper) o;
+ return listener.equals(that.listener);
+ }
+
+ @Override
+ public int hashCode() {
+ return listener.hashCode();
+ }
+ }
+
+ /** Returns the remaining available commands after removing disabled commands. */
+ private static Commands filterCommands(Commands availableCommands, Commands disabledCommands) {
+ if (disabledCommands.size() == 0) {
+ return availableCommands;
+ }
+
+ Commands.Builder builder = new Commands.Builder();
+ for (int i = 0; i < availableCommands.size(); i++) {
+ int command = availableCommands.get(i);
+ builder.addIf(command, !disabledCommands.contains(command));
+ }
+ return builder.build();
+ }
}
diff --git a/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java b/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java
index fe220b1946..a4436ccdab 100644
--- a/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java
+++ b/library/common/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java
@@ -155,6 +155,11 @@ public final class ListenerSet {
}
}
+ /** Returns the number of added listeners. */
+ public int size() {
+ return listeners.size();
+ }
+
/**
* Adds an event that is sent to the listeners when {@link #flushEvents} is called.
*
diff --git a/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java b/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java
new file mode 100644
index 0000000000..57ae90b29c
--- /dev/null
+++ b/library/common/src/test/java/com/google/android/exoplayer2/ForwardingPlayerTest.java
@@ -0,0 +1,386 @@
+/*
+ * Copyright (C) 2021 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
+ *
+ * http://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 com.google.android.exoplayer2;
+
+import static com.google.android.exoplayer2.Player.COMMAND_PLAY_PAUSE;
+import static com.google.android.exoplayer2.Player.COMMAND_PREPARE_STOP;
+import static com.google.android.exoplayer2.Player.COMMAND_SEEK_TO_MEDIA_ITEM;
+import static com.google.android.exoplayer2.Player.EVENT_AVAILABLE_COMMANDS_CHANGED;
+import static com.google.android.exoplayer2.Player.EVENT_PLAYBACK_STATE_CHANGED;
+import static com.google.android.exoplayer2.Player.STATE_READY;
+import static com.google.android.exoplayer2.util.Assertions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import android.os.Looper;
+import androidx.annotation.Nullable;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.testutil.StubExoPlayer;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Queue;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentMatcher;
+import org.mockito.InOrder;
+import org.robolectric.shadows.ShadowLooper;
+
+/** Unit test for {@link ForwardingPlayer}. */
+@RunWith(AndroidJUnit4.class)
+public class ForwardingPlayerTest {
+ @Test
+ public void getAvailableCommands_withDisabledCommands_filtersDisabledCommands() {
+ Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+
+ assertThat(forwardingPlayer.getAvailableCommands())
+ .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE));
+ }
+
+ @Test
+ public void getAvailableCommands_playerAvailableCommandsChanged_returnsFreshCommands() {
+ FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+ assertThat(forwardingPlayer.getAvailableCommands())
+ .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE));
+ player.setAvailableCommands(
+ buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM));
+
+ assertThat(forwardingPlayer.getAvailableCommands())
+ .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_SEEK_TO_MEDIA_ITEM));
+ }
+
+ @Test
+ public void isCommandAvailable_withDisabledCommands_filtersDisabledCommands() {
+ Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+
+ assertThat(forwardingPlayer.isCommandAvailable(COMMAND_PLAY_PAUSE)).isTrue();
+ assertThat(forwardingPlayer.isCommandAvailable(COMMAND_PREPARE_STOP)).isFalse();
+ }
+
+ @Test
+ public void setDisabledCommands_triggersOnCommandsAvailableChanged() {
+ Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ Player.Listener listener = mock(Player.Listener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.addListener(listener);
+
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+ ShadowLooper.idleMainLooper();
+
+ InOrder inOrder = inOrder(listener);
+ inOrder.verify(listener).onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE));
+ inOrder
+ .verify(listener)
+ .onEvents(
+ same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED)));
+ inOrder.verifyNoMoreInteractions();
+ }
+
+ @Test
+ public void setDisabledCommands_withoutChangingAvailableCommands_noCallbackTriggered() {
+ Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ Player.Listener listener = mock(Player.Listener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.addListener(listener);
+
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM));
+ ShadowLooper.idleMainLooper();
+
+ verifyNoMoreInteractions(listener);
+ }
+
+ @Test
+ public void setDisabledCommands_multipleTimes_availableCommandsUpdated() {
+ Player player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM));
+ assertThat(forwardingPlayer.getAvailableCommands())
+ .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP));
+
+ forwardingPlayer.setDisabledCommands(
+ buildCommands(COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM));
+ assertThat(forwardingPlayer.getAvailableCommands())
+ .isEqualTo(buildCommands(COMMAND_PLAY_PAUSE));
+ }
+
+ @Test
+ public void onCommandsAvailableChanged_listenerChangesCommandsRecursively_secondCallbackCalled() {
+ FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ Player.Listener listener =
+ spy(
+ new Player.Listener() {
+ @Override
+ public void onAvailableCommandsChanged(Player.Commands availableCommands) {
+ // The callback changes the forwarding player's disabled commands triggering
+ // exactly one more callback.
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+ }
+ });
+ forwardingPlayer.addListener(listener);
+
+ Player.Commands updatedCommands =
+ buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM);
+ player.setAvailableCommands(updatedCommands);
+ player.forwardingListener.onAvailableCommandsChanged(updatedCommands);
+ ShadowLooper.idleMainLooper();
+
+ InOrder inOrder = inOrder(listener);
+ inOrder
+ .verify(listener)
+ .onAvailableCommandsChanged(
+ buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM));
+ inOrder
+ .verify(listener)
+ .onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE, COMMAND_SEEK_TO_MEDIA_ITEM));
+ inOrder
+ .verify(listener)
+ .onEvents(
+ same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED)));
+ inOrder.verifyNoMoreInteractions();
+ }
+
+ @Test
+ public void
+ interceptingOnAvailableCommandsChanged_withDisabledCommands_filtersDisabledCommands() {
+ FakePlayer player = new FakePlayer();
+ Player.Listener listener = mock(Player.Listener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.addListener(listener);
+
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_PREPARE_STOP));
+ ShadowLooper.idleMainLooper();
+ // Setting the disabled commands did not affect the available commands, hence no callback was
+ // triggered.
+ verifyNoMoreInteractions(listener);
+
+ // The wrapped player advertises new available commands.
+ Player.Commands updatedCommands = buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ player.setAvailableCommands(updatedCommands);
+ player.forwardingListener.onAvailableCommandsChanged(updatedCommands);
+ ShadowLooper.idleMainLooper();
+ verify(listener).onAvailableCommandsChanged(buildCommands(COMMAND_PLAY_PAUSE));
+ verify(listener)
+ .onEvents(
+ same(forwardingPlayer), argThat(new EventsMatcher(EVENT_AVAILABLE_COMMANDS_CHANGED)));
+ verifyNoMoreInteractions(listener);
+ }
+
+ @Test
+ public void
+ interceptingOnAvailableCommandsChanged_withDisabledCommandsButAvailableCommandsNotChanged_doesNotForwardCallback() {
+ FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ Player.Listener listener = mock(Player.Listener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+ forwardingPlayer.addListener(listener);
+
+ // Disable commands that do not affect the available commands.
+ forwardingPlayer.setDisabledCommands(buildCommands(COMMAND_SEEK_TO_MEDIA_ITEM));
+ ShadowLooper.idleMainLooper();
+ verifyNoMoreInteractions(listener);
+
+ // The wrapped player advertises new available commands which, after filtering the disabled
+ // commands, do not change the available commands.
+ Player.Commands updatedCommands =
+ buildCommands(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP, COMMAND_SEEK_TO_MEDIA_ITEM);
+ player.setAvailableCommands(updatedCommands);
+ player.forwardingListener.onAvailableCommandsChanged(updatedCommands);
+ ShadowLooper.idleMainLooper();
+
+ verifyNoMoreInteractions(listener);
+ }
+
+ @Test
+ public void removeListener_removesListenerFromPlayer() {
+ FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ Player.Listener listener = mock(Player.Listener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+
+ forwardingPlayer.addListener(listener);
+ assertThat(player.forwardingListener).isNotNull();
+ forwardingPlayer.removeListener(listener);
+ assertThat(player.forwardingListener).isNull();
+ }
+
+ @Test
+ public void addEventListener_forwardsEventListenerEvents() {
+ FakePlayer player = new FakePlayer(COMMAND_PLAY_PAUSE, COMMAND_PREPARE_STOP);
+ Player.EventListener eventListener = mock(Player.EventListener.class);
+ ForwardingPlayer forwardingPlayer = new ForwardingPlayer(player);
+
+ forwardingPlayer.addListener(eventListener);
+ player.forwardingListener.onPlaybackStateChanged(STATE_READY);
+ ShadowLooper.idleMainLooper();
+
+ InOrder inOrder = inOrder(eventListener);
+ inOrder.verify(eventListener).onPlaybackStateChanged(STATE_READY);
+ inOrder
+ .verify(eventListener)
+ .onEvents(same(forwardingPlayer), argThat(new EventsMatcher(EVENT_PLAYBACK_STATE_CHANGED)));
+ inOrder.verifyNoMoreInteractions();
+ }
+
+ @Test
+ public void forwardingListener_overridesAllListenerMethods() throws Exception {
+ // Check with reflection that ForwardingListener in ForwardingPlayer overrides all Listener
+ // methods.
+ Class> forwardingListenerClass = getNestedClass("ForwardingListener");
+ List publicListenerMethods = getPublicMethods(Player.Listener.class);
+ for (Method method : publicListenerMethods) {
+ assertThat(
+ forwardingListenerClass.getDeclaredMethod(
+ method.getName(), method.getParameterTypes()))
+ .isNotNull();
+ }
+ }
+
+ @Test
+ public void eventListenerWrapper_overridesAllEventListenerMethods() throws Exception {
+ // Check with reflection that EventListenerWrapper in ForwardingPlayer overrides all
+ // EventListener methods.
+ Class> listenerWrapperClass = getNestedClass("EventListenerWrapper");
+ List publicListenerMethods = getPublicMethods(Player.EventListener.class);
+ for (Method method : publicListenerMethods) {
+ assertThat(
+ listenerWrapperClass.getDeclaredMethod(method.getName(), method.getParameterTypes()))
+ .isNotNull();
+ }
+ }
+
+ private static class FakePlayer extends StubExoPlayer {
+ private Commands availableCommands;
+ /**
+ * Supports up to 1 registered listener, named deliberately forwardingListener to emphasize its
+ * purpose.
+ */
+ @Nullable private Listener forwardingListener;
+
+ public FakePlayer() {
+ this.availableCommands = Commands.EMPTY;
+ }
+
+ public FakePlayer(@Command int... commands) {
+ this.availableCommands = new Commands.Builder().addAll(commands).build();
+ }
+
+ @Override
+ public void addListener(Listener listener) {
+ checkState(this.forwardingListener == null);
+ this.forwardingListener = listener;
+ }
+
+ @Override
+ public void removeListener(Listener listener) {
+ checkState(this.forwardingListener.equals(listener));
+ this.forwardingListener = null;
+ }
+
+ @Override
+ public Commands getAvailableCommands() {
+ return availableCommands;
+ }
+
+ @Override
+ public Looper getApplicationLooper() {
+ return Looper.getMainLooper();
+ }
+
+ public void setAvailableCommands(Commands availableCommands) {
+ this.availableCommands = availableCommands;
+ }
+ }
+
+ private static Player.Commands buildCommands(@Player.Command int... commands) {
+ return new Player.Commands.Builder().addAll(commands).build();
+ }
+
+ private Class> getNestedClass(String className) {
+ for (Class> declaredClass : ForwardingPlayer.class.getDeclaredClasses()) {
+ if (declaredClass.getSimpleName().equals(className)) {
+ return declaredClass;
+ }
+ }
+ throw new IllegalStateException();
+ }
+
+ private static class EventsMatcher implements ArgumentMatcher {
+ private final int[] events;
+
+ private EventsMatcher(int... events) {
+ this.events = events;
+ }
+
+ @Override
+ public boolean matches(Player.Events argument) {
+ if (events.length != argument.size()) {
+ return false;
+ }
+ for (int event : events) {
+ if (!argument.contains(event)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ /** Returns all the methods of Java interface. */
+ private static List getPublicMethods(Class> anInterface) {
+ assertThat(anInterface.isInterface()).isTrue();
+ // Run a BFS over all extended interfaces to inspect them all.
+ Queue> interfacesQueue = new ArrayDeque<>();
+ interfacesQueue.add(anInterface);
+ Set> interfaces = new HashSet<>();
+ while (!interfacesQueue.isEmpty()) {
+ Class> currentInterface = interfacesQueue.remove();
+ if (interfaces.add(currentInterface)) {
+ Collections.addAll(interfacesQueue, currentInterface.getInterfaces());
+ }
+ }
+
+ List list = new ArrayList<>();
+ for (Class> currentInterface : interfaces) {
+ for (Method method : currentInterface.getDeclaredMethods()) {
+ if (Modifier.isPublic(method.getModifiers())) {
+ list.add(method);
+ }
+ }
+ }
+
+ return list;
+ }
+}